From 4c02ac425918f0613e0e7af8a399c4bf79273f22 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 31 Jul 2026 22:32:49 +0200 Subject: [PATCH] feat(runtime): own deferred set-position residence --- docs/architecture/acdream-architecture.md | 3 + .../retail-divergence-register.md | 16 +- .../2026-07-31-canonical-set-position.md | 155 ++ src/AcDream.Core/AcDream.Core.csproj | 3 + .../Physics/CollisionWorldState.cs | 1 + .../Physics/ShadowObjectRegistry.cs | 228 +- .../Entities/ParentAttachmentState.cs | 99 +- .../Entities/RuntimeEntityDirectory.cs | 6 + .../RuntimeEntityObjectEventStream.cs | 109 +- .../Entities/RuntimeEntityObjectLifetime.cs | 65 +- .../Entities/RuntimeEntityRecord.cs | 3 + src/AcDream.Runtime/GameRuntime.cs | 3 +- src/AcDream.Runtime/GameRuntimeEvents.cs | 5 + .../Physics/RuntimePhysicsState.cs | 170 +- .../Physics/RuntimeSetPositionState.cs | 2014 ++++++++++++++ .../Physics/ShadowSetPositionCommitTests.cs | 167 ++ .../Entities/ParentAttachmentStateTests.cs | 95 + .../Physics/RuntimeSetPositionStateTests.cs | 2449 +++++++++++++++++ 18 files changed, 5557 insertions(+), 34 deletions(-) create mode 100644 src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs create mode 100644 tests/AcDream.Core.Tests/Physics/ShadowSetPositionCommitTests.cs create mode 100644 tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionStateTests.cs diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md index b46152fa..76a38787 100644 --- a/docs/architecture/acdream-architecture.md +++ b/docs/architecture/acdream-architecture.md @@ -241,6 +241,9 @@ src/ Physics/ RuntimePhysicsState.cs -> per-session engine/cache/scratch/shadows, collision receipts, bodies/hosts/worksets + RuntimeSetPositionState.cs -> exact placement/lost-cell operations, + authored mover retention, ordered host + receipts, and collision-generation wake RuntimeRemotePhysicsUpdater.cs -> presentation-free remote simulation RuntimeOrdinaryPhysicsUpdater.cs -> presentation-free object simulation RuntimeProjectile.cs -> canonical projectile component/prediction owner diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 4c37cc4a..bf6ef268 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -80,6 +80,18 @@ loader branch there. Slice 4B must preserve the flag while mapping successful deferred placement to exact-cell, generation-scoped asynchronous admission; the presence of the flag in the immutable request is not claimed as exactness. +AP-1/AD-1 checkpoint (placement Slice 4B1, 2026-07-31): Runtime now owns the +exact accepted placement/lost-cell transaction, atomic body/contact/cell/ +shadow/workset commit, adjusted retained frame, authored mover preparation, +exact-cell/generation wake, append/swap lost buckets, a bounded indexed +deadline heap, independent root/direct-child deadlines, and revisioned ordered +host receipts. Both rows remain open until 4B2 cuts graphical and no-window +production routes over, quiesces active placement before invoking the dormant +collision-retirement entry, and binds portal +authority to `RuntimeWorldTransitState`. AD-2 remains the deliberate async +readiness/requeue adaptation. See +`docs/research/2026-07-31-canonical-set-position.md`. + | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| | ~~AD-53~~ | **RETIRED 2026-07-31 (Campaign P Slice 1B).** `Transition.CliffSlide` now consumes only `collision_info.last_known_contact_plane.N`, exactly as retail does. The invented `LastWalkablePlane -> LastKnownContactPlane -> UnitZ` fallback chain is gone; invalid/default or parallel data takes retail's degenerate `OK_TS` return. | `src/AcDream.Core/Physics/TransitionTypes.cs` (`CliffSlide`); `tests/AcDream.Core.Tests/Physics/RetailEdgeResponseOrderingTests.cs` | — | — | `CTransition::cliff_slide` pc:272397 (0050a6d0); `last_known_contact_plane` maintenance pc:272659-272668 (~0050ad07) | @@ -92,7 +104,7 @@ the presence of the flag in the immutable request is not claimed as exactness. | AD-51 | **Filed at Campaign N slice N4 (2026-07-29).** The inbound sequence tracker keeps a reclaimed-word pool (per-parked-word draw ordinals + `PriorityQueue` consumed lowest-draw-order-first) that retail has no counterpart for: on a VALIDATED cleartext `RejectRetransmit`, the word the gap walk parked for the reject packet's OWN sequence is removed, every later-drawn parked word is shifted down one position, and the excess word feeds the next fresh draws. | `src/AcDream.Core.Net/Transport/InboundSequenceTracker.cs` (`OnCleartextRejectSequence`, `NextWord`, `ParkedWord`); trigger at `src/AcDream.Core.Net/WorldSession.cs` (RejectRetransmit consumption) | Retail's inbound invariant is "every missing id was an encrypted packet whose keystream word the server drew" — true against retail servers, whose cleartext packets always borrow live sequences (acks/NAKs reuse `highestIDSent_`; `FlowQueue::TransmitNewPackets @ 0x00547A60` sequences only reliable packets). ACE breaks it in exactly one place: `RejectRetransmit` takes a FRESH sequence through FlushPackets, cleartext, drawing NO S2C keystream word, and is cached (ACE NetworkSession.cs:299-304, :722-725, :743-748). Without the reclaim, our gap walk pre-draws a word for that id, the inbound stream runs permanently one word ahead, and every later encrypted packet fails checksum — the N2 desync class reintroduced through the reject path. The pool is provably empty against a retail server, so retail behavior is untouched. Reject BODY ids keep the N2 discard (their words were drawn on both sides — consumed-in-place). Known unreachable corner: a reject whose own id later appears inside another reject's body (first reject pruned after 120 s of sustained loss with the session alive) would discard a never-drawn word; probabilistically impossible against ACE's 60 s silence timeout and the 0.6 s NAK cadence. | Against a hypothetical non-ACE server that assigns fresh cleartext sequences to packets OTHER than RejectRetransmit, those ids would still mis-park with no reclaim trigger — inbound desync. Only ACE-family servers exist for this client today, and ACE has exactly the one path. | `SharedNet::ProcessNewestSeqNum @ 0x00541930` (the gap walk whose invariant ACE breaks); `SharedNet::HandleEmptyAck @ 0x005448F0` (retail's reject consumption — body ids only, no own-sequence machinery because retail never needs it) | | AD-52 | **Filed at Campaign N slice N6 (2026-07-29).** The inbound fragment assembler evicts incomplete partial messages 60 s after their last ACCEPTED fragment (swept on retail's 5 s flush cadence from `ReliableTransport.Sweep`) and remembers the last 64 completed multi-fragment sequences in a ring so a late duplicate fragment of an already-completed message drops instead of allocating a fresh partial that can never complete. Retail's prune target and horizon differ: its 5 s-TTL `FlushTimedOutEphInfo` table holds ephemeral-blob ORDERING stamps (the AD-49 deferral), not partial payloads. | `src/AcDream.Core.Net/Packets/FragmentAssembler.cs` (`SweepExpired`, `PartialTtlSeconds`, `CompletedRingSize`); cadence in `src/AcDream.Core.Net/Transport/ReliableTransport.cs` (`AssemblerSweepSeconds`) | N4's RejectRetransmit abandonment made an unrecoverable partial a REACHABLE permanent state: ACE pruned a fragment-bearing packet from its 120 s S2C cache and told us to stop asking, so that blob can never complete — without a TTL it leaks for the session's lifetime. 60 s is ≫ every recovery horizon (0.6 s NAK cadence, ACE's 2 s ack, the 120 s cache) and the stamp refreshes on every accepted fragment (retail's own re-stamp rule, `ArrivedEphInfo::UpdateNetBlobID @ 0x0054AE00`), so only a server-abandoned partial can age out — a merely-slow one cannot. The ring is bounded (64 × 4 B) and its only false negative (a duplicate arriving after 64 later completions) degrades to the pre-N6 behavior, now reclaimed by the TTL. | If ACE ever legitimately re-served a fragment of a completed message under a REUSED fragment sequence within the ring window, it would be dropped — but fragment sequences are strictly monotonic per session (ACE SessionConnectionData.FragmentSequence), so reuse cannot happen inside one connection. An evicted partial whose fragments later straggle in re-partials and re-evicts — bounded churn, no corruption. | `Indicator::FlushTimedOutEphInfo @ 0x0054A3D0` (the 5.0 s flush gate at 0x0054A3DC); `ArrivedEphInfo::fTimedOut @ 0x0054AE30` (per-entry 5.0 s TTL); `ArrivedEphInfo::UpdateNetBlobID @ 0x0054AE00` (re-stamp on update); retail has no partial-payload TTL — its blob layer trusts its own NAK persistence, which N4's ACE-mandated abandonment (`SharedNet::HandleEmptyAck @ 0x005448F0`) breaks | | AD-38 | Outgoing teleport viewports retire when retail's quantized animation level exceeds the last captured visible level 1022 (index 96), suppressing levels 1023/1024 up to 20.2 ms before retail's literal `elapsed >= 1.0` state edge. Incoming fades retain the exact timer. | `src/AcDream.Core/World/TeleportAnimSequencer.cs` (`OutgoingViewportReachedTerminalProjection`) | An uncapped 2000 FPS pass can publish the finite tunnel at levels 1023/1024 even though the paired 2013 retail capture switches viewports after 1022. The table-level cutover preserves the captured visible viewport ordering without throttling the application. | Exit sound, viewport replacement, and logout tunnel entry can occur at most two easing-table quanta (about 20.2 ms) earlier than retail's logical timer. | `UIGlobals::GetAnimLevel @ 0x004EE540`; `gmSmartBoxUI::UseTime @ 0x004D6E30`; paired retail/acdream captures documented in `docs/research/2026-07-15-retail-portal-space-pseudocode.md` | -| AD-1 | Lost-cell machinery replaced by recoverable outdoor demote (**#107** safety net) + outdoor-restore `max(terrainZ, z)` under-terrain lift; retail goes `GotoLostCell` | `src/AcDream.Core/Physics/PhysicsEngine.cs:553` (+ :808) | acdream has no lost-cell state machine; outdoor landcell is the recoverable equivalent; the #107 auto-entry hold should make the demote branch unreachable | Gap in the hold → player committed to outdoor terrain inside/under a building (fake-grounded spawn, fall-through); a legit below-heightmap server restore is silently lifted — upward warp vs server | `GotoLostCell` pc:283418; `SetPositionInternal` 0x00515bd0, pc:283892-283945 | +| AD-1 | **NARROWED 2026-07-31 (placement Slice 4B1).** Runtime now owns exact lost-cell residence, adjusted frame retention, 25-second root/direct-child lifetime, generation-scoped wake, and host Withdraw/Place receipts. Production graphical/headless authoritative placement still routes through the legacy recoverable outdoor demote and outdoor-restore `max(terrainZ, z)` lift until 4B2 cuts those routes over. | `src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs`; legacy route in `src/AcDream.Core/Physics/PhysicsEngine.cs` | The canonical owner is dormant and separately gated so landing it cannot change the accepted production world before the complete route/host-ack cutover. | Until 4B2, a production gap can still commit an outdoor approximation inside/under a building or lift a legitimate below-heightmap restore instead of entering the now-available Runtime lost-cell owner. | `GotoLostCell` pc:283418; `SetPositionInternal` 0x00515bd0, pc:283892-283945 | | AD-2 | Async readiness gates replace retail's synchronous destination cell load. **#229 refinement (2026-07-20):** login and F751 portal-space exit now share `WorldRevealReadinessBarrier`, so neither path can expose the normal viewport until the same render-publication, composite-texture, and collision domains converge. A hydratable indoor claim requires its owning Near-tier static/EnvCell mesh set, destination composites, and exact EnvCell physics (`IsSpawnCellReady`); an outdoor claim requires those render domains plus terrain/collision residency for the required Near ring. Hard-recenter generations and tier-aware completion application prevent stale overlapping loads/unloads or Far/Near jobs from opening or erasing the gate; mesh upload remains separate from balanced landblock ownership. Claims beyond NumCells still take the loud unhydratable-placement path. `RuntimeWorldTransitState` owns the shared reveal generation, accepted readiness, transit correlation, and exact generation/cell-scoped host-acknowledgement suffix. `WorldRevealCoordinator` is a graphical adapter holding only App resource receipts; normalized Runtime checkpoints observe ownership without defining another readiness path. **Slice E3 refinement (2026-07-24):** the same generation now publishes an immediate `WorldGenerationQuiescence` edge: old-world drawing/spatial queries, simulation/effect clocks, reconciliation, targeting, and 3-D audio stop while retained physical teardown advances through metered cursors and destination network/UI/streaming/readiness remain live. **Slice E4 refinement (2026-07-24):** accepted render/physics/static publication may span update frames through retained exact cursors, but reveal still consumes only the completed spatial/render-ready generation; building and EnvCell snapshots remain invisible until complete and the final spatial identity swap stays observer-atomic. **Slice E5 refinement (2026-07-24):** the reveal generation owns one exact destination reservation across every typed budget dimension. Stale completion cannot consume or clear its replacement, and hydratable incomplete content is never force-revealed; portal transit retains the DAT tunnel and centered retail wait cue until readiness converges. The hold→materialize→regain-control lifecycle remains owned by `TeleportAnimSequencer`. | `src/AcDream.Runtime/World/RuntimeWorldTransitState.cs`; `src/AcDream.App/Streaming/WorldRevealCoordinator.cs`; `src/AcDream.App/Streaming/WorldGenerationQuiescence.cs`; `src/AcDream.App/Streaming/WorldRevealReadinessBarrier.cs`; `src/AcDream.App/Streaming/StreamingOriginRecenterCoordinator.cs`; `src/AcDream.App/Streaming/LandblockPresentationPipeline.cs`; `src/AcDream.App/Streaming/StreamingController.cs`; `src/AcDream.App/Rendering/PortalTunnelPresentation.cs`; `src/AcDream.App/UI/PortalWaitNoticeController.cs`; `src/AcDream.App/Streaming/GpuWorldState.cs` (`IsRenderReady`); `src/AcDream.App/Rendering/Wb/LandblockSpawnAdapter.cs`; `src/AcDream.Core/Physics/PhysicsEngine.cs` (`IsSpawnCellReady`, `IsNeighborhoodTerrainResident`) | This is the asynchronous equivalent of retail leaving `SmartBox::position_update_complete` false while `CellManager::blocking_for_cells` is set: neither initial login nor portal arrival may reveal or continue simulating an old/partial collision world, a terrain-only Far shell, or a published-but-not-drawable GPU landblock. Indoor does not require a terrain heightmap, only the owning render landblock and exact EnvCell. | Gate opens early → grey/untextured first login or portal reveal, free-fall, wrong-cell rooting, missing scenery, or a still-active old generation; predicate never satisfies (streamer/DAT/upload failure) → login remains behind the world render gate, while portal transit remains in the authored tunnel and presents the centered wait cue after five seconds. | `SmartBox::UseTime` 0x00455410; `gmSmartBoxUI::UseTime` 0x004D6E30; `gmSmartBoxUI::EndTeleportAnimation` 0x004D65A0 | | AD-5 | Outdoor `point_in_cell` is an identity compare against the global XY-column cell from `LandDefs.AdjustToOutside` (no per-cell containment test) | `src/AcDream.Core/Physics/CellTransit.cs:865` | Landcells are disjoint 24 m columns — identity-compare against the column under the sphere centre is exactly equivalent to retail's per-candidate test | If block-origin/lcoord math is wrong at a landblock seam, the compare silently never matches — outdoor membership freezes at boundaries (the pre-#106 symptom) | `find_cell_list` pick pc:308788-308825; `CLandCell::point_in_cell` (get_block_offset pc:308804) | | ~~AD-6~~ | **RETIRED 2026-07-31 (placement/streaming Slice 3B).** Cell/cache/topology/building/static-shadow publication plus every retained non-suspended owner touching or withdrawn from the prefix is one Runtime-owned collision generation. Retained includes dynamics and adjacent-root statics; only target-root statics are superseded by the authored replacement. App and Headless build one shared off-side `CollisionWorldState` through one-work-unit preparation/capture/seal cursors. Admission captures the active root in O(1); a stable landblock/owner slot suffix materializes non-target leaves incrementally, so resident-world size cannot become a synchronous clone spike. Reusable per-prefix owner slots and one Runtime-scoped versioned journal replace event-time exact-copy fanout: repeated live mutations coalesce by owner, every draft reconciles only that owner's latest exact state one owner per seal call, discovered relevant owners receive scoped exact updates, and visited unrelated owners receive only a cheap coalesced dirty notification before metered replay. Once topology sealing finishes, observed owners temporarily write through exactly until same-call activation; the finite pre-seal queue therefore drains even under continuous multi-owner movement. New drafts start at their captured journal suffix; old slots are superseded rather than reused behind live cursors and compact through the same meter. Unrelated churn therefore never restarts or starves target capture/sealing. Deterministically ordered concurrent preparations receive committed—not merely sealed—peer deltas and rebase one cache, graph, landblock, or owner leaf per seal step; cancellation therefore cannot leak unpublished topology. Demotion/withdrawal cancels a matching queued or active rebase, suppresses the prefix in unfinished source scans, and retires one owner/cache/graph/outdoor leaf per seal call. The complete previous generation remains queryable until one zero-managed-byte volatile root transfer in the same update-thread call as final reconciliation; that preserves PhysicsDataCache, CellGraph, PhysicsEngine, and ShadowObjectRegistry facade identity, revokes staging, and requires no quiet frame. A stale admission or staging failure disposes only that private generation and cannot withdraw the active world or invalidate a newer admission. Authored same-ID target statics, live-current-cell changes, owner departure/reuse, newly relevant seam-crossing statics, and teardown remain coherent across drafts; empty per-prefix owner containers are reclaimed without invalidating captured seal cursors. The commit clears repaired withdrawal markers before its single notification/readiness acknowledgement, so no optional hydration callback can omit reflood and no observer sees mixed old/new cells. | `src/AcDream.Runtime/Physics/RuntimePhysicsState.cs` (`PrepareCollisionGeneration`, `AdvanceCollisionGenerationPreparation`, `AdvanceCollisionGenerationSeal`, `CommitCollisionGeneration`); `src/AcDream.Core/Physics/CollisionWorldState.cs`; `PhysicsDataCache.cs`; `PhysicsEngine.cs`; `ShadowObjectRegistry.cs`; `src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs`; `src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs`; `tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsStateTests.cs`; `tests/AcDream.App.Tests/Streaming/LandblockPhysicsPublisherTests.cs`; `tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs` | — | — | `CObjCell::init_objects` → `CPhysicsObj::recalc_cross_cells`, 0x0052b420 / 0x00515a30; `CPhysicsObj::SetPositionInternal` shadow replacement tail 0x00515330 | @@ -140,7 +152,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| -| AP-1 | **NARROWED 2026-07-31 (placement/streaming Slice 4A).** Core now exposes the pure retail `SetPosition` → `AdjustPosition` → `CheckPositionInternal` → `find_valid_position` transaction, including exact sphere initialization, two distinct placement validators, step-down schedule, force classes, flags/scatter, signed no-slide predicate, error values, and an explicit deferred-residence result. Production zero-delta routes deliberately still use the legacy nearest-in-Z/terrain-lift resolver because its `ResolveResult` cannot represent a successful lost-cell transition. Slice 4B must cut every authoritative route to the canonical packet and install the Runtime lost-cell owner before this row retires. | `src/AcDream.Core/Physics/PhysicsSetPosition.cs`; `src/AcDream.Core/Physics/PhysicsEngine.cs`; `src/AcDream.Core/Physics/TransitionTypes.cs`; `tests/AcDream.Core.Tests/Physics/PhysicsSetPositionTests.cs`; `docs/research/2026-07-31-canonical-set-position.md` | Split preserves production behavior while the immutable placement mechanism and its oracle gates land independently | Until 4B, fresh spawn, same-generation refresh, authoritative Position, portal arrival, external teleport, parent detach, pickup release, and world-drop hydration can still run the old approximation and cannot park/wake an exact lost-cell frame | `CPhysicsObj::SetPosition` 0x005160C0; `SetPositionInternal` 0x00515BD0; `AdjustPosition` 0x00511D80; `CheckPositionInternal` 0x00511E90; `CTransition::find_valid_position` 0x0050C310; `find_placement_position` 0x0050C170; `validate_placement_transition` 0x0050ADC0; `validate_placement` 0x0050B210 | +| AP-1 | **NARROWED 2026-07-31 (placement/streaming Slice 4B1).** Core exposes the pure retail `SetPosition` transaction and Runtime now owns its exact accepted operation, complete canonical commit, deferred residence, lifetime, generation wake, and revisioned host receipts. Production zero-delta routes deliberately remain on the legacy resolver until 4B2 supplies exact authored mover preparation and cuts graphical/headless inbound families to this dormant owner. | `src/AcDream.Core/Physics/PhysicsSetPosition.cs`; `src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs`; `tests/AcDream.Core.Tests/Physics/PhysicsSetPositionTests.cs`; `tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionStateTests.cs`; `docs/research/2026-07-31-canonical-set-position.md` | The mechanism, ownership, and failure/reentrancy gates land independently without partially changing production behavior. | Until 4B2, fresh spawn, same-generation refresh, authoritative Position, portal arrival, external teleport, parent detach, pickup release, and world-drop hydration can still run the old approximation despite the canonical owner now existing. | `CPhysicsObj::SetPosition` 0x005160C0; `SetPositionInternal` 0x00515BD0; `AdjustPosition` 0x00511D80; `CheckPositionInternal` 0x00511E90; `CTransition::find_valid_position` 0x0050C310; `find_placement_position` 0x0050C170; `validate_placement_transition` 0x0050ADC0; `validate_placement` 0x0050B210 | | ~~AP-3~~ | **RETIRED 2026-07-31 (Campaign P Slice 1B).** `TransitionalInsert` now returns `OK_TS` immediately for every valid contact plane. Its ordinary StepDown tail is reachable only from invalid contact and retains the retail Contact / `!sphere_path.step_down` / check-cell / ObjectInfo.StepDown gates plus the exact one-versus-two-sphere probe split. | `src/AcDream.Core/Physics/TransitionTypes.cs` (`TransitionalInsert`, `GetStepDownProbePlan`); `tests/AcDream.Core.Tests/Physics/RetailEdgeResponseOrderingTests.cs` | — | — | `CTransition::transitional_insert` 0x0050B6F0, named-retail pseudo-C pc:273191–273307 | | ~~AP-4~~ | **RETIRED 2026-07-31 (Campaign P Slice 1B).** `EdgeSlideAfterStepDownFailed` now evaluates retail Branch 1 (`!OnWalkable || !EdgeSlide` → restore + `OK_TS`) before the steep-contact `CliffSlide` branch. The former compensation is removed. | `src/AcDream.Core/Physics/TransitionTypes.cs` (`EdgeSlideAfterStepDownFailed`); `tests/AcDream.Core.Tests/Physics/RetailEdgeResponseOrderingTests.cs` | — | — | `CTransition::edge_slide` 0x0050B3D0, named-retail pseudo-C pc:273001–273090 | diff --git a/docs/research/2026-07-31-canonical-set-position.md b/docs/research/2026-07-31-canonical-set-position.md index f166645e..6c1ae257 100644 --- a/docs/research/2026-07-31-canonical-set-position.md +++ b/docs/research/2026-07-31-canonical-set-position.md @@ -140,3 +140,158 @@ collision-handler mapping, force-class policy, explicit shadow actions, null current-cell wakeup, ten-record scratch exhaustion, exact scatter ordering, failed-probe scratch lifetime, and deferred-scatter stop. The legacy public Resolve fixture remains unchanged until Slice 4B. + +## Slice 4B1 — Runtime residence owner + +Slice 4B1 adds the presentation-independent half of the cutover without +changing a production graphical route yet. `RuntimeSetPositionState` accepts +an exact entity/position token before graphical DAT preparation, consumes the +immutable Core result, and commits body, contact, full cell, shadows, object +clock, and Runtime spatial worksets before publishing one ordered placement +delta. The delta carries the exact `RuntimeEntityKey`, session lifetime, +position/spatial/placement versions, adjusted cell, collision generation, and +optional portal-authority shape. A throwing or unavailable host does not roll +simulation back: Runtime republishes the same projection token until the +exact FIFO head is acknowledged. A newer operation changes an already- +published token to `Discard` and increments its projection revision, so an +acknowledgement of the previously observed Place/Withdraw cannot consume an +unseen Discard. It is never silently forgotten. An unacknowledged lost-cell +Withdraw transfers intact to a replacing accepted operation and remains the +FIFO head before that replacement may publish Place. + +The successful missing-cell path owns retail's residence shape: + +```text +SetPosition -> OK + DeferredCell + retain adjusted Position and the same PhysicsBody/components + clear only Active and suspend the object clock + withdraw Runtime spatial worksets and shadow rows + retain shadow registration and exact authored mover request + append parentless root to (exact cell, collision generation) + arm independent exact-key 25 s deadlines for root + direct children + publish Withdraw + +exact cell generation resident + Withdraw acknowledged + re-run SetPosition with retained authored spheres and CurrentCellId=null + atomically install complete result + publish Place +``` + +Lost-cell membership buckets use retail-shaped append plus swap-remove. +Destruction deadlines use an exact-key hash plus a bounded indexed min-heap, +the allocation-bounded modern equivalent of retail's hash + +`PQueueArray` priority owner. Rearm/removal updates the exact heap node; +there are no stale tombstones. Only committed, current direct children from +the parent-incarnation ordered CHILDLIST participate; unresolved or future +relations cannot inherit a deadline. Parent, pickup, delete, GUID +replacement, newer Position, reset, and disposal cancel the exact incarnation +and use leave-world semantics rather than a wakeable lost entry. The dormant +collision-retirement entry parks non-static parentless indoor roots and, for +complete withdrawal, affected outdoor roots. It performs a complete preflight +and rejects overlap with any active accepted/host-ack-pending placement before +mutating one resident. It then installs every affected canonical lost +residence and operation before publishing the first synchronous Withdraw, so +an observer re-entering for a later root inherits that root's exact pending +Withdraw instead of having its newly accepted placement cancelled by the +retirement loop. 4B2 must quiesce that placement prefix before invoking the +entry in the same transaction that installs host acknowledgements. + +Runtime retains the last accepted prepared mover request, including exact +off-center/two-sphere payloads, scale, flags, and step values. A cold resident +with no prepared request still withdraws atomically but remains explicitly in +`AwaitingPreparation`; it cannot wake through an invented empty-sphere shape. +Preparation is cached only after Core accepts it as Committed or DeferredCell. +A rejected/malformed preparation keeps the same accepted token retryable and +cannot replace the last validated mover used by later collision retirement. +Runtime's host boundary rejects only non-finite consumed frame/shape values; +retail-valid oddities such as nonpositive authored scale remain untouched. +Likewise, a non-deferred wake failure retains the withdrawn body, independent +25-second lifetime, and exact operation: invalid arguments return to +AwaitingPreparation and re-index for the next exact generation, while other +world-placement failures also re-index. The last successful DeferredCell +result and adjusted frame remain canonical across the failed attempt. No +failed wake can leave a live entity withdrawn without a Runtime owner. The +graphical/no-window cutover in 4B2 supplies that exact preparation token. + +Retail `CPhysicsObj::SetPositionInternal` (`0x00515BD0`) calls +`prepare_to_enter_world` (`0x00511FA0`) only when `this->cell == 0`. +Consequently the physics `update_time` (`PhysicsBody.LastUpdateTime`) and +active bit are reset only on the cellless-to-world edge. Ordinary same-cell or +cross-cell in-world SetPosition preserves the already-consumed physics clock; +entering the lost-cell residence also preserves it until the eventual +cellless wake commit. Runtime pins both sides and does not inherit the older +graphical teleport helper's unconditional timer reset. + +The wake timestamp is in the Runtime simulation-time domain, never Unix/UTC: +`GameRuntime` binds its instance `GameRuntimeClock` through the entity/physics +owner, and RetryDeferred samples `SimulationTimeSeconds`. Standalone Runtime +fixtures without a bound game clock retain the accepted command time. This is +a Runtime dependency only; no App delegate enters the owner. + +The canonical commit also installs every SetPosition-derived body invariant +before host publication: Contact/OnWalkable/WaterContact, the current contact +plane and slope `GroundNormal`, Sliding plus its normal, and the complete +StationaryFall/Stop/Stuck encoding. Named retail +`CPhysicsObj::SetPositionInternal(CTransition const*)` (`0x00515330`) copies +only the transition's current contact plane/water flag, walkability, sliding +normal/valid flag, and collision state (`0x005153E5–0x005154FE`). It does not +publish `last_known_contact_plane` or the SpherePath walkable polygon on this +path, so those ordinary-update-only fields are intentionally absent from the +immutable SetPosition result and remain unchanged. A zero expected velocity +version in host preparation preserves the nonzero version captured when the +operation was accepted; an intervening Vector/Movement therefore suppresses +only the stale collision-velocity response. A bodyless cancellation terminates +without fabricating a PhysicsBody or a host Withdraw projection. The two time +domains remain explicit: `PhysicsBody.LastUpdateTime` consumes the instance +simulation clock, while `IRuntimeRemotePlacement.LastServerPositionTime` +remains Unix-UTC receipt time because the remote stale-velocity owner ages it +against `RuntimePhysicsState.UtcNowSeconds`. A deferred wake therefore cannot +make fresh authoritative remote velocity appear years old. Runtime preparation +also applies the existing retail `PositionFrameValidation` before Core or +prepared-mover caching, and caps synchronous Scatter/RandomScatter work at 64 +attempts; this keeps valid authored retail request shapes while rejecting a +hostile `uint.MaxValue` loop at the authority boundary. + +Two boundaries intentionally remain open for 4B2: + +- `RuntimePortalPlacementAuthority` validates immutable token shape only; + 4B2 must bind it to active `RuntimeWorldTransitState` generation, teleport + sequence, destination, and host acknowledgement before reveal. +- Runtime applies the canonical collision-report state but fails the handler + return closed. Retail returns report/track success, not collision presence; + the per-object report/tracking owner required for that boolean is not yet in + Runtime, and the former environment/object-presence guess is forbidden. + +AD-2 remains the explicit async adaptation: collision readiness can publish in +a different frame from retail's blocking load. A failed wake is safely re- +indexed to the next exact generation instead of inheriting retail's +synchronous assumption. When older unbound survivors meet newer entities +already indexed into that future generation, Runtime merges them into one +bucket with the older survivor order first and retains one bucket-order entry. +AP-1 and AD-1 remain open until 4B2 removes the legacy graphical/headless +placement paths. + +`RuntimeSetPositionStateTests` pins accepted-before-preparation ownership, +portal-shape rejection, canonical-before-projection ordering, retry and +reentrant discard, cross-landblock adjusted-cell park/wake, same-body +identity, retained contact/water/sliding/velocity, exact generation gating, +authored two-sphere retention, cold preparation, bounded priority-deadline +rearm/cancel, zero-allocation empty ticks, independent ordered direct-child +deadlines, actual collision-admission supersession/invalidation, missing exact +indoor-cell generation rebind/wake, collision demotion/withdrawal preflight, +failed-wake retry, malformed-preparation retry without cache poisoning, +simulation-clock-domain wake, velocity-version preservation, derived body-bit +writeback, immediate remote-velocity survival across the simulation/UTC clock +boundary, invalid cell/frame/quaternion and extreme-scatter rejection before +Core/caching, bodyless cancellation, newer Position/pickup/parent/delete, GUID +reuse, reset, and complete index/node terminal convergence. Existing zero- +allocation collision-generation gates remain unchanged on the no-deferred +fast path. + +The warmed immediate commit/ack route currently measures exactly **1,880 +managed bytes per operation** in the Release Runtime test host (1,000 +iterations after 64 warmups); the regression gate caps it at 2,048 bytes. +This dormant-path result is an explicit 4B2 activation blocker rather than a +claim of allocation-free production readiness: 4B2 must either pool/remove +the operation and projection envelopes or record an approved measured budget +before routing frame-frequency placement through this owner. diff --git a/src/AcDream.Core/AcDream.Core.csproj b/src/AcDream.Core/AcDream.Core.csproj index cf3567d5..8300307e 100644 --- a/src/AcDream.Core/AcDream.Core.csproj +++ b/src/AcDream.Core/AcDream.Core.csproj @@ -23,6 +23,9 @@ <_Parameter1>AcDream.Runtime + + <_Parameter1>AcDream.Runtime.Tests + diff --git a/src/AcDream.Core/Physics/CollisionWorldState.cs b/src/AcDream.Core/Physics/CollisionWorldState.cs index a2a182c2..bc92d882 100644 --- a/src/AcDream.Core/Physics/CollisionWorldState.cs +++ b/src/AcDream.Core/Physics/CollisionWorldState.cs @@ -25,6 +25,7 @@ internal sealed class CollisionWorldState internal Dictionary> ShadowCells { get; } = new(); internal Dictionary> ShadowEntityCells { get; } = new(); internal HashSet SuspendedShadowEntities { get; } = new(); + internal Dictionary> SuspendedShadowEntityCells { get; } = new(); internal Dictionary> WithdrawnPrefixesByOwner { get; } = new(); internal Dictionary> ShadowEntityShapes { get; } = new(); internal Dictionary diff --git a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs index d79e14c5..874addd3 100644 --- a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs +++ b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs @@ -33,6 +33,8 @@ public sealed class ShadowObjectRegistry _collisionWorld.Current.ShadowEntityCells; // for deregistration private HashSet _suspendedEntities => _collisionWorld.Current.SuspendedShadowEntities; + private Dictionary> _suspendedEntityCells => + _collisionWorld.Current.SuspendedShadowEntityCells; // Rows withdrawn because a touched landblock streamed out. The owner may // be seeded in an adjacent still-resident landblock, so its remaining rows // cannot by themselves tell RefloodLandblock that this prefix needs repair. @@ -691,6 +693,218 @@ public sealed class ShadowObjectRegistry reg.State, reg.Flags, seedCellId, reg.IsStatic); } + /// + /// Installs the shadow-list suffix of one canonical retail + /// CPhysicsObj::SetPositionInternal commit. The transition has + /// already selected whether retail recalculates, replaces, or preserves + /// the owner's cross-cell list; this method consumes that decision + /// without running a second placement/flood oracle. + /// + internal void CommitSetPosition( + uint entityId, + Vector3 worldPosition, + Quaternion worldRotation, + uint seedCellId, + float worldOffsetX, + float worldOffsetY, + PhysicsShadowCommitAction action, + System.Collections.Immutable.ImmutableArray crossCellIds) + { + if (!_entityReg.TryGetValue( + entityId, + out RegistrationRecord? registration)) + { + return; + } + + switch (action) + { + case PhysicsShadowCommitAction.None: + RefreshPositionRows( + entityId, + registration, + worldPosition, + worldRotation, + seedCellId); + return; + case PhysicsShadowCommitAction.Recalculate: + UpdatePosition( + entityId, + worldPosition, + worldRotation, + worldOffsetX, + worldOffsetY, + landblockId: seedCellId & 0xFFFF0000u, + seedCellId); + return; + case PhysicsShadowCommitAction.Replace: + if (crossCellIds.IsDefaultOrEmpty) + { + RefreshPositionRows( + entityId, + registration, + worldPosition, + worldRotation, + seedCellId); + return; + } + ReplacePositionRows( + entityId, + registration, + worldPosition, + worldRotation, + seedCellId, + crossCellIds); + return; + case PhysicsShadowCommitAction.Preserve: + RefreshPositionRows( + entityId, + registration, + worldPosition, + worldRotation, + seedCellId); + return; + default: + throw new ArgumentOutOfRangeException(nameof(action)); + } + } + + private void RefreshPositionRows( + uint entityId, + RegistrationRecord registration, + Vector3 worldPosition, + Quaternion worldRotation, + uint seedCellId) + { + if ((_entityToCells.TryGetValue( + entityId, + out List? retainedCells) + || _suspendedEntityCells.TryGetValue( + entityId, + out retainedCells)) + && retainedCells.Count != 0) + { + ReplacePositionRows( + entityId, + registration, + worldPosition, + worldRotation, + seedCellId, + retainedCells); + return; + } + + _entityReg[entityId] = registration with + { + SeedCellId = seedCellId, + EntityWorldPos = worldPosition, + EntityWorldRot = worldRotation, + }; + BumpOwnerVersion(entityId); + } + + private void ReplacePositionRows( + uint entityId, + RegistrationRecord registration, + Vector3 worldPosition, + Quaternion worldRotation, + uint seedCellId, + IReadOnlyList cellIds) + { + if (_entityToCells.TryGetValue( + entityId, + out List? previousCells)) + { + for (int index = 0; index < previousCells.Count; index++) + { + if (_cells.TryGetValue( + previousCells[index], + out List? entries)) + { + RemoveOwnerRows(entries, entityId); + } + } + } + + _suspendedEntities.Remove(entityId); + _suspendedEntityCells.Remove(entityId); + _entityReg[entityId] = registration with + { + SeedCellId = seedCellId, + EntityWorldPos = worldPosition, + EntityWorldRot = worldRotation, + }; + + var exactCells = new List(cellIds.Count); + for (int index = 0; index < cellIds.Count; index++) + { + uint cellId = cellIds[index]; + if (cellId == 0u || exactCells.Contains(cellId)) + continue; + exactCells.Add(cellId); + } + + if (registration.IsMultiPart + && _entityShapes.TryGetValue( + entityId, + out IReadOnlyList? shapes)) + { + foreach (ShadowShape shape in shapes) + { + Vector3 partWorldPosition = worldPosition + + Vector3.Transform(shape.LocalPosition, worldRotation); + Quaternion partWorldRotation = worldRotation + * shape.LocalRotation; + var entry = new ShadowEntry( + entityId, + shape.GfxObjId, + partWorldPosition, + partWorldRotation, + shape.Radius, + shape.CollisionType, + shape.CylHeight, + shape.Scale, + registration.State, + registration.Flags, + shape.LocalPosition, + shape.LocalRotation); + for (int index = 0; index < exactCells.Count; index++) + AddEntryToCell(entry, exactCells[index]); + } + } + else + { + var entry = new ShadowEntry( + entityId, + registration.GfxObjId, + worldPosition, + worldRotation, + registration.Radius, + registration.CollisionType, + registration.CylHeight, + registration.Scale, + registration.State, + registration.Flags); + for (int index = 0; index < exactCells.Count; index++) + AddEntryToCell(entry, exactCells[index]); + } + + if (exactCells.Count == 0) + _entityToCells.Remove(entityId); + else + _entityToCells[entityId] = exactCells; + if (_withdrawnPrefixesByOwner.TryGetValue( + entityId, + out HashSet? withdrawn)) + { + for (int index = 0; index < exactCells.Count; index++) + withdrawn.Remove(exactCells[index] & 0xFFFF0000u); + if (withdrawn.Count == 0) + _withdrawnPrefixesByOwner.Remove(entityId); + } + BumpOwnerVersion(entityId); + } + /// /// Removes an entity from every cell collision list while retaining the /// exact registration and shape payload needed to restore it later. @@ -706,6 +920,7 @@ public sealed class ShadowObjectRegistry if (_entityToCells.TryGetValue(entityId, out var cellIds)) { + _suspendedEntityCells[entityId] = new List(cellIds); foreach (uint cellId in cellIds) { if (_cells.TryGetValue(cellId, out var list)) @@ -924,7 +1139,8 @@ public sealed class ShadowObjectRegistry bool existed = _entityReg.ContainsKey(entityId) || _entityToCells.ContainsKey(entityId) || _entityShapes.ContainsKey(entityId) - || _suspendedEntities.Contains(entityId); + || _suspendedEntities.Contains(entityId) + || _suspendedEntityCells.ContainsKey(entityId); if (_entityToCells.TryGetValue(entityId, out var cellIds)) { foreach (var cellId in cellIds) @@ -937,6 +1153,7 @@ public sealed class ShadowObjectRegistry _entityShapes.Remove(entityId); _entityReg.Remove(entityId); _suspendedEntities.Remove(entityId); + _suspendedEntityCells.Remove(entityId); _withdrawnPrefixesByOwner.Remove(entityId); if (existed && publishMutation) { @@ -1065,6 +1282,7 @@ public sealed class ShadowObjectRegistry _entityShapes.Remove(eid); _entityReg.Remove(eid); _suspendedEntities.Remove(eid); + _suspendedEntityCells.Remove(eid); _withdrawnPrefixesByOwner.Remove(eid); } } @@ -1367,6 +1585,9 @@ public sealed class ShadowObjectRegistry return false; } _entityToCells.TryGetValue(entityId, out List? cells); + _suspendedEntityCells.TryGetValue( + entityId, + out List? suspendedCells); _entityShapes.TryGetValue( entityId, out IReadOnlyList? shapes); @@ -1394,6 +1615,7 @@ public sealed class ShadowObjectRegistry cells is null ? null : new List(cells), rows, _suspendedEntities.Contains(entityId), + suspendedCells is null ? null : new List(suspendedCells), withdrawn is null ? null : new HashSet(withdrawn)); return true; } @@ -1405,6 +1627,8 @@ public sealed class ShadowObjectRegistry _entityShapes[state.EntityId] = state.Shapes; if (state.Suspended) _suspendedEntities.Add(state.EntityId); + if (state.SuspendedCellIds is not null) + _suspendedEntityCells[state.EntityId] = state.SuspendedCellIds; if (state.WithdrawnPrefixes is not null) { _withdrawnPrefixesByOwner[state.EntityId] = state.WithdrawnPrefixes; @@ -1614,6 +1838,7 @@ public sealed class ShadowObjectRegistry List? CellIds, IReadOnlyList Rows, bool Suspended, + List? SuspendedCellIds, HashSet? WithdrawnPrefixes); internal sealed record PreparedShadowCellRows( @@ -1629,6 +1854,7 @@ public sealed class ShadowObjectRegistry _cells.Clear(); _entityToCells.Clear(); _suspendedEntities.Clear(); + _suspendedEntityCells.Clear(); _withdrawnPrefixesByOwner.Clear(); _entityShapes.Clear(); _entityReg.Clear(); diff --git a/src/AcDream.Runtime/Entities/ParentAttachmentState.cs b/src/AcDream.Runtime/Entities/ParentAttachmentState.cs index 2d64f63d..355d139e 100644 --- a/src/AcDream.Runtime/Entities/ParentAttachmentState.cs +++ b/src/AcDream.Runtime/Entities/ParentAttachmentState.cs @@ -16,6 +16,7 @@ public sealed class ParentAttachmentState private readonly Dictionary _stagedByChild = new(); private readonly Dictionary _recoveryByChild = new(); private readonly Dictionary _lastAcceptedByChild = new(); + private readonly Dictionary> _committedChildrenByParent = new(); public int UnresolvedRelationCount => _unresolvedByChild.Values.Sum(queue => queue.Count); @@ -148,6 +149,9 @@ public sealed class ParentAttachmentState out ParentAttachmentRelation committed) && committed == relation; + public bool HasCommittedParent(uint childGuid) => + _lastAcceptedByChild.ContainsKey(childGuid); + public bool IsPending( ParentAttachmentRelation relation, ParentProjectionCandidateKind kind) => @@ -184,7 +188,20 @@ public sealed class ParentAttachmentState { return false; } + + RemoveCommittedChild(relation.ChildGuid); _lastAcceptedByChild[relation.ChildGuid] = relation; + var parent = new ParentIncarnation( + relation.ParentGuid, + relation.ParentInstanceSequence); + if (!_committedChildrenByParent.TryGetValue( + parent, + out List? children)) + { + children = []; + _committedChildrenByParent.Add(parent, children); + } + children.Add(relation.ChildGuid); _stagedByChild.Remove(relation.ChildGuid); _recoveryByChild[relation.ChildGuid] = relation; return true; @@ -233,16 +250,34 @@ public sealed class ParentAttachmentState return result.ToArray(); } + /// + /// Returns only the exact direct children currently committed to one + /// parent incarnation. Lost-cell destruction follows retail's live + /// CHILDLIST and must not capture staged, unresolved, or future-generation + /// relations that merely reuse the same parent GUID. + /// + public IReadOnlyList ChildrenAttachedToParent( + uint parentGuid, + ushort parentInstanceSequence) + { + var parent = new ParentIncarnation(parentGuid, parentInstanceSequence); + return _committedChildrenByParent.TryGetValue( + parent, + out List? children) + ? children + : Array.Empty(); + } + public void RemoveObject(uint guid) { _stagedByChild.Remove(guid); _recoveryByChild.Remove(guid); - _lastAcceptedByChild.Remove(guid); + RemoveCommittedChild(guid); _unresolvedByChild.Remove(guid); RemoveParentReferences(_stagedByChild, guid); RemoveParentReferences(_recoveryByChild, guid); - RemoveParentReferences(_lastAcceptedByChild, guid); + RemoveCommittedParentReferences(guid); uint[] children = _unresolvedByChild.Keys.ToArray(); for (int i = 0; i < children.Length; i++) @@ -268,10 +303,10 @@ public sealed class ParentAttachmentState relation => relation.WaitOwner is ParentAttachmentWaitOwner.Parent); _stagedByChild.Remove(guid); _recoveryByChild.Remove(guid); - _lastAcceptedByChild.Remove(guid); + RemoveCommittedChild(guid); RemoveParentReferences(_stagedByChild, guid); RemoveParentReferences(_recoveryByChild, guid); - RemoveParentReferences(_lastAcceptedByChild, guid); + RemoveCommittedParentReferences(guid); FilterParentCandidates( guid, relation => relation.ParentInstanceSequence == replacementGeneration @@ -292,10 +327,10 @@ public sealed class ParentAttachmentState relation => relation.WaitOwner is ParentAttachmentWaitOwner.Parent); _stagedByChild.Remove(guid); _recoveryByChild.Remove(guid); - _lastAcceptedByChild.Remove(guid); + RemoveCommittedChild(guid); RemoveParentReferences(_stagedByChild, guid); RemoveParentReferences(_recoveryByChild, guid); - RemoveParentReferences(_lastAcceptedByChild, guid); + RemoveCommittedParentReferences(guid); FilterParentCandidates( guid, relation => PhysicsTimestampGate.IsNewer( @@ -311,14 +346,14 @@ public sealed class ParentAttachmentState { _stagedByChild.Remove(childGuid); _recoveryByChild.Remove(childGuid); - _lastAcceptedByChild.Remove(childGuid); + RemoveCommittedChild(childGuid); } public void RemoveChild(uint childGuid) { _stagedByChild.Remove(childGuid); _recoveryByChild.Remove(childGuid); - _lastAcceptedByChild.Remove(childGuid); + RemoveCommittedChild(childGuid); _unresolvedByChild.Remove(childGuid); } @@ -328,6 +363,50 @@ public sealed class ParentAttachmentState _stagedByChild.Clear(); _recoveryByChild.Clear(); _lastAcceptedByChild.Clear(); + foreach (List children in _committedChildrenByParent.Values) + children.Clear(); + _committedChildrenByParent.Clear(); + } + + private void RemoveCommittedChild(uint childGuid) + { + if (!_lastAcceptedByChild.Remove( + childGuid, + out ParentAttachmentRelation relation)) + { + return; + } + + var parent = new ParentIncarnation( + relation.ParentGuid, + relation.ParentInstanceSequence); + if (!_committedChildrenByParent.TryGetValue( + parent, + out List? children)) + { + return; + } + + int childIndex = children.IndexOf(childGuid); + if (childIndex >= 0) + { + int lastIndex = children.Count - 1; + children[childIndex] = children[lastIndex]; + children.RemoveAt(lastIndex); + } + + if (children.Count == 0) + _committedChildrenByParent.Remove(parent); + } + + private void RemoveCommittedParentReferences(uint parentGuid) + { + uint[] children = _lastAcceptedByChild + .Where(pair => pair.Value.ParentGuid == parentGuid) + .Select(pair => pair.Key) + .ToArray(); + for (int i = 0; i < children.Length; i++) + RemoveCommittedChild(children[i]); } private static void RemoveParentReferences( @@ -377,6 +456,10 @@ public sealed class ParentAttachmentState else _unresolvedByChild[childGuid] = retained; } + + private readonly record struct ParentIncarnation( + uint ServerGuid, + ushort InstanceSequence); } public readonly record struct ParentAttachmentRelation( diff --git a/src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs b/src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs index 02d2722b..73308b39 100644 --- a/src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs +++ b/src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs @@ -254,6 +254,12 @@ public sealed class RuntimeEntityDirectory record.AdvanceMovementCommit(); } + public void AdvancePlacementCommit(RuntimeEntityRecord record) + { + EnsureKnown(record); + record.AdvancePlacementCommit(); + } + public void AdvanceParentCommit(RuntimeEntityRecord record) { EnsureKnown(record); diff --git a/src/AcDream.Runtime/Entities/RuntimeEntityObjectEventStream.cs b/src/AcDream.Runtime/Entities/RuntimeEntityObjectEventStream.cs index 5cc7e5c3..9935b453 100644 --- a/src/AcDream.Runtime/Entities/RuntimeEntityObjectEventStream.cs +++ b/src/AcDream.Runtime/Entities/RuntimeEntityObjectEventStream.cs @@ -1,4 +1,5 @@ using AcDream.Core.Items; +using AcDream.Runtime.Physics; namespace AcDream.Runtime.Entities; @@ -14,6 +15,11 @@ public interface IRuntimeEntityObjectEventSource IDisposable Subscribe(IRuntimeEntityObjectObserver observer); } +public interface IRuntimePlacementObserver +{ + void OnPlacement(in RuntimePlacementDelta delta); +} + /// /// One synchronous, generation-stamped commit stream for the canonical entity /// directory and retained object table. It owns no cross-frame queue and @@ -31,6 +37,7 @@ public sealed class RuntimeEntityObjectEventStream private readonly object _observerGate = new(); private readonly List _pendingDispatch = []; private IRuntimeEntityObjectObserver[] _observers = []; + private IRuntimePlacementObserver[] _placementObservers = []; private Func _generation = static () => default; private Func _frameNumber = static () => 0UL; private bool _contextBound; @@ -53,6 +60,8 @@ public sealed class RuntimeEntityObjectEventStream public ulong LastSequence => _sequencer.LastSequence; public int SubscriberCount => Volatile.Read(ref _observers).Length; + public int PlacementSubscriberCount => + Volatile.Read(ref _placementObservers).Length; public int PendingDispatchCount => _pendingDispatch.Count; public bool IsDispatching => _dispatching; public long DispatchFailureCount { get; private set; } @@ -107,6 +116,26 @@ public sealed class RuntimeEntityObjectEventStream return new ObserverSubscription(this, observer); } + public IDisposable SubscribePlacement(IRuntimePlacementObserver observer) + { + ArgumentNullException.ThrowIfNull(observer); + lock (_observerGate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + IRuntimePlacementObserver[] current = _placementObservers; + if (Array.IndexOf(current, observer) >= 0) + { + throw new InvalidOperationException( + "The Runtime placement observer is already subscribed."); + } + var replacement = new IRuntimePlacementObserver[current.Length + 1]; + Array.Copy(current, replacement, current.Length); + replacement[^1] = observer; + Volatile.Write(ref _placementObservers, replacement); + } + return new PlacementObserverSubscription(this, observer); + } + /// /// Allocates the next stamp for another domain on the same Runtime event /// surface. J4–J6 will move those remaining domain publishers into @@ -132,6 +161,15 @@ public sealed class RuntimeEntityObjectEventStream EnqueueAndDrain(PendingDispatch.ForEntity(delta)); } + internal void PublishPlacement( + in RuntimePlacementProjectionSnapshot placement) + { + var delta = new RuntimePlacementDelta( + NextStamp(), + placement); + EnqueueAndDrain(PendingDispatch.ForPlacement(delta)); + } + public void Dispose() { lock (_observerGate) @@ -140,6 +178,7 @@ public sealed class RuntimeEntityObjectEventStream return; _disposed = true; Volatile.Write(ref _observers, []); + Volatile.Write(ref _placementObservers, []); _objects.Cleared -= OnObjectsCleared; _objects.ObjectRemovalClassified -= OnObjectRemoved; _objects.ObjectMoved -= OnObjectMoved; @@ -155,6 +194,7 @@ public sealed class RuntimeEntityObjectEventStream if (_disposed) return; Volatile.Write(ref _observers, []); + Volatile.Write(ref _placementObservers, []); } } @@ -245,7 +285,7 @@ public sealed class RuntimeEntityObjectEventStream RuntimeEntityDelta delta = pending.Entity; observer.OnEntity(in delta); } - else + else if (pending.Kind is PendingDispatchKind.Inventory) { RuntimeInventoryDelta delta = pending.Inventory; observer.OnInventory(in delta); @@ -256,6 +296,24 @@ public sealed class RuntimeEntityObjectEventStream RecordDispatchFailure(error); } } + + if (pending.Kind is PendingDispatchKind.Placement) + { + IRuntimePlacementObserver[] placementObservers = + Volatile.Read(ref _placementObservers); + foreach (IRuntimePlacementObserver observer in placementObservers) + { + try + { + RuntimePlacementDelta delta = pending.Placement; + observer.OnPlacement(in delta); + } + catch (Exception error) + { + RecordDispatchFailure(error); + } + } + } } private void RecordDispatchFailure(Exception error) @@ -295,24 +353,54 @@ public sealed class RuntimeEntityObjectEventStream } } + private void UnsubscribePlacement(IRuntimePlacementObserver observer) + { + lock (_observerGate) + { + IRuntimePlacementObserver[] current = _placementObservers; + int index = Array.IndexOf(current, observer); + if (index < 0) + return; + var replacement = new IRuntimePlacementObserver[current.Length - 1]; + if (index != 0) + Array.Copy(current, 0, replacement, 0, index); + if (index != current.Length - 1) + { + Array.Copy( + current, + index + 1, + replacement, + index, + current.Length - index - 1); + } + Volatile.Write(ref _placementObservers, replacement); + } + } + private enum PendingDispatchKind : byte { Entity, Inventory, + Placement, } private readonly record struct PendingDispatch( PendingDispatchKind Kind, RuntimeEntityDelta Entity, - RuntimeInventoryDelta Inventory) + RuntimeInventoryDelta Inventory, + RuntimePlacementDelta Placement) { public static PendingDispatch ForEntity( RuntimeEntityDelta entity) => - new(PendingDispatchKind.Entity, entity, default); + new(PendingDispatchKind.Entity, entity, default, default); public static PendingDispatch ForInventory( RuntimeInventoryDelta inventory) => - new(PendingDispatchKind.Inventory, default, inventory); + new(PendingDispatchKind.Inventory, default, inventory, default); + + public static PendingDispatch ForPlacement( + RuntimePlacementDelta placement) => + new(PendingDispatchKind.Placement, default, default, placement); } private sealed class ObserverSubscription( @@ -325,4 +413,17 @@ public sealed class RuntimeEntityObjectEventStream public void Dispose() => Interlocked.Exchange(ref _owner, null)?.Unsubscribe(observer); } + + + private sealed class PlacementObserverSubscription( + RuntimeEntityObjectEventStream owner, + IRuntimePlacementObserver observer) + : IDisposable + { + private RuntimeEntityObjectEventStream? _owner = owner; + + public void Dispose() => + Interlocked.Exchange(ref _owner, null)? + .UnsubscribePlacement(observer); + } } diff --git a/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs b/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs index 1b2b8a95..4d7be7c5 100644 --- a/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs +++ b/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs @@ -28,6 +28,7 @@ public readonly record struct RuntimeEntityObjectOwnershipSnapshot( int EquipmentOwnerCount, int PendingMoveCount, int StreamSubscriberCount, + int PlacementStreamSubscriberCount, long StreamDispatchFailureCount, bool HasLastStreamDispatchFailure, int PendingDispatchCount, @@ -51,6 +52,7 @@ public readonly record struct RuntimeEntityObjectOwnershipSnapshot( && EquipmentOwnerCount == 0 && PendingMoveCount == 0 && StreamSubscriberCount == 0 + && PlacementStreamSubscriberCount == 0 && PendingDispatchCount == 0 && !IsDispatching && !IsSessionClearInProgress; @@ -95,10 +97,14 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable public RuntimeEntityObjectLifetime( uint firstLocalEntityId = RuntimeEntityDirectory.FirstLocalEntityId, - TimeProvider? timeProvider = null) + TimeProvider? timeProvider = null, + IGameRuntimeClock? gameClock = null) { Entities = new RuntimeEntityDirectory(firstLocalEntityId); - Physics = new RuntimePhysicsState(Entities, timeProvider: timeProvider); + Physics = new RuntimePhysicsState( + Entities, + timeProvider: timeProvider, + gameClock: gameClock); Objects = new ClientObjectTable(); // AP-129 (Campaign P Slice P4 review fix, 2026-07-30): the physics // entry-restriction gate (ObjectInfo.CheckEntryRestrictions) resolves @@ -111,44 +117,51 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable EntityView = views.Entities; InventoryView = views.Inventory; Events = new RuntimeEntityObjectEventStream(Entities, Objects); + Physics.SetPosition.BindEventStream(Events); } internal RuntimeEntityObjectLifetime( PhysicsDataCache physicsDataCache, uint firstLocalEntityId = RuntimeEntityDirectory.FirstLocalEntityId, - TimeProvider? timeProvider = null) + TimeProvider? timeProvider = null, + IGameRuntimeClock? gameClock = null) { ArgumentNullException.ThrowIfNull(physicsDataCache); Entities = new RuntimeEntityDirectory(firstLocalEntityId); Physics = new RuntimePhysicsState( Entities, physicsDataCache, - timeProvider); + timeProvider, + gameClock); Objects = new ClientObjectTable(); Physics.Engine.Objects = Objects; var views = new RuntimeEntityObjectViews(Entities, Objects); EntityView = views.Entities; InventoryView = views.Inventory; Events = new RuntimeEntityObjectEventStream(Entities, Objects); + Physics.SetPosition.BindEventStream(Events); } internal RuntimeEntityObjectLifetime( PhysicsEngine physicsEngine, uint firstLocalEntityId = RuntimeEntityDirectory.FirstLocalEntityId, - TimeProvider? timeProvider = null) + TimeProvider? timeProvider = null, + IGameRuntimeClock? gameClock = null) { ArgumentNullException.ThrowIfNull(physicsEngine); Entities = new RuntimeEntityDirectory(firstLocalEntityId); Physics = new RuntimePhysicsState( Entities, physicsEngine, - timeProvider); + timeProvider, + gameClock); Objects = new ClientObjectTable(); Physics.Engine.Objects = Objects; var views = new RuntimeEntityObjectViews(Entities, Objects); EntityView = views.Entities; InventoryView = views.Inventory; Events = new RuntimeEntityObjectEventStream(Entities, Objects); + Physics.SetPosition.BindEventStream(Events); } public RuntimeEntityDirectory Entities { get; } @@ -176,6 +189,7 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable Objects.EquipmentOwnerCount, Objects.PendingMoveCount, Events.SubscriberCount, + Events.PlacementSubscriberCount, Events.DispatchFailureCount, Events.LastDispatchFailure is not null, Events.PendingDispatchCount, @@ -421,6 +435,10 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable { EnsureNotDisposed(); ArgumentNullException.ThrowIfNull(canonical); + RuntimePlacementCancellationReceipt cancellation = + Physics.SetPosition.Forget( + canonical, + releasePreparedMover: true); Physics.RemoveSpatialProjection(canonical); Entities.SetRemoteMotion(canonical, null); Entities.SetRemoteMotionBindingInProgress(canonical, false); @@ -432,6 +450,7 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable Entities.SetPhysicsBodyAcquisitionInProgress(canonical, false); Entities.SetHasPartArray(canonical, false); Entities.ReleaseLocalId(canonical); + Physics.SetPosition.PublishCancellation(cancellation); } /// @@ -510,6 +529,8 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable Entities.RefreshSnapshot(canonical, accepted); Entities.AdvancePositionAuthority(canonical); + RuntimePlacementCancellationReceipt cancellation = + Physics.SetPosition.Forget(canonical); Entities.SuspendObjectClock(canonical); Entities.SetFullCell(canonical, 0u, 0u); Entities.ParentAttachments.EndChildProjection(update.Guid); @@ -520,7 +541,8 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable () => acknowledgeProjection?.Invoke(canonical), RuntimeEntityChange.Withdrawn, () => canonical.PositionAuthorityVersion == positionVersion - && canonical.SpatialAuthorityVersion == spatialVersion); + && canonical.SpatialAuthorityVersion == spatialVersion, + cancellation); } public bool TryApplyCreateParent( @@ -596,6 +618,8 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable return false; } + RuntimePlacementCancellationReceipt cancellation = + Physics.SetPosition.Forget(canonical); Entities.SuspendObjectClock(canonical); Entities.SetFullCell(canonical, 0u, 0u); ulong spatialVersion = canonical.SpatialAuthorityVersion; @@ -605,7 +629,8 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable RuntimeEntityChange.Withdrawn, () => canonical.PositionAuthorityVersion == positionAuthorityVersion - && canonical.SpatialAuthorityVersion == spatialVersion); + && canonical.SpatialAuthorityVersion == spatialVersion, + cancellation); } public bool TryApplyMotion( @@ -764,6 +789,9 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable }; } + RuntimePlacementCancellationReceipt cancellation = acceptedPosition + ? Physics.SetPosition.Forget(canonical) + : default; Entities.RefreshSnapshot( canonical, snapshot, @@ -793,7 +821,8 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable ? RuntimeEntityChange.Rebucketed : RuntimeEntityChange.Updated, () => canonical.PositionAuthorityVersion == positionVersion - && canonical.SpatialAuthorityVersion == spatialVersion); + && canonical.SpatialAuthorityVersion == spatialVersion, + cancellation); } public bool CommitRebucket( @@ -915,8 +944,13 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable && active.Incarnation == delete.InstanceSequence && Entities.RemoveActive(active)) { + RuntimePlacementCancellationReceipt cancellation = + Physics.SetPosition.Forget( + active, + releasePreparedMover: true); retiredCanonical = active; Entities.RetainTeardown(active); + Physics.SetPosition.PublishCancellation(cancellation); PublishEntity( removeRetainedObject ? RuntimeEntityChange.Deleted @@ -985,10 +1019,12 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable return Array.Empty(); _sessionClearInProgress = true; + Physics.SetPosition.ResetSession(); Entities.BeginSessionClear(); RuntimeEntityRecord[] active = Entities.ActiveRecords.ToArray(); foreach (RuntimeEntityRecord canonical in active) { + Physics.SetPosition.Forget(canonical); if (!Entities.RemoveActive(canonical)) continue; Entities.RetainTeardown(canonical); @@ -1119,6 +1155,8 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable Entities.RefreshSnapshot(canonical, accepted); Entities.AdvancePositionAuthority(canonical); + RuntimePlacementCancellationReceipt cancellation = + Physics.SetPosition.Forget(canonical); ulong positionVersion = canonical.PositionAuthorityVersion; ulong spatialVersion = canonical.SpatialAuthorityVersion; return AcknowledgeProjectionAndPublish( @@ -1126,7 +1164,8 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable () => acknowledgeProjection?.Invoke(canonical), RuntimeEntityChange.Updated, () => canonical.PositionAuthorityVersion == positionVersion - && canonical.SpatialAuthorityVersion == spatialVersion); + && canonical.SpatialAuthorityVersion == spatialVersion, + cancellation); } private void PublishEntity( @@ -1138,8 +1177,12 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable RuntimeEntityRecord canonical, Action acknowledgeProjection, RuntimeEntityChange change, - Func matchesCommittedMutation) + Func matchesCommittedMutation, + RuntimePlacementCancellationReceipt cancellation = default) { + Physics.SetPosition.PublishCancellation(cancellation); + if (!IsExpectedCanonical(canonical, matchesCommittedMutation)) + return false; try { acknowledgeProjection(); diff --git a/src/AcDream.Runtime/Entities/RuntimeEntityRecord.cs b/src/AcDream.Runtime/Entities/RuntimeEntityRecord.cs index 57ffb766..f180b915 100644 --- a/src/AcDream.Runtime/Entities/RuntimeEntityRecord.cs +++ b/src/AcDream.Runtime/Entities/RuntimeEntityRecord.cs @@ -52,6 +52,7 @@ public sealed class RuntimeEntityRecord public uint RawPhysicsState { get; internal set; } public PhysicsStateFlags FinalPhysicsState { get; internal set; } public ulong SpatialAuthorityVersion { get; private set; } + public ulong PlacementCommitVersion { get; private set; } public ulong PhysicsStateMutationVersion { get; private set; } /// @@ -136,6 +137,8 @@ public sealed class RuntimeEntityRecord internal void AdvanceMovementCommit() => MovementCommitVersion++; + internal void AdvancePlacementCommit() => PlacementCommitVersion++; + internal void AdvanceParentCommit() => ParentCommitVersion++; internal void AdvanceObjDescAuthority() => ObjDescAuthorityVersion++; diff --git a/src/AcDream.Runtime/GameRuntime.cs b/src/AcDream.Runtime/GameRuntime.cs index fcad9f14..1625d47b 100644 --- a/src/AcDream.Runtime/GameRuntime.cs +++ b/src/AcDream.Runtime/GameRuntime.cs @@ -178,7 +178,8 @@ public sealed class GameRuntime context.EntityObjects = new RuntimeEntityObjectLifetime( dependencies.FirstLocalEntityId, - dependencies.TimeProvider); + dependencies.TimeProvider, + clock); construction.Own(context.EntityObjects); Fault( GameRuntimeConstructionPoint.EntityObjectsCreated, diff --git a/src/AcDream.Runtime/GameRuntimeEvents.cs b/src/AcDream.Runtime/GameRuntimeEvents.cs index 376a3426..6183ea23 100644 --- a/src/AcDream.Runtime/GameRuntimeEvents.cs +++ b/src/AcDream.Runtime/GameRuntimeEvents.cs @@ -1,4 +1,5 @@ using AcDream.Core.Combat; +using AcDream.Runtime.Physics; namespace AcDream.Runtime; @@ -50,6 +51,10 @@ public readonly record struct RuntimeEntityDelta( RuntimeEntityChange Change, RuntimeEntitySnapshot Entity); +public readonly record struct RuntimePlacementDelta( + RuntimeEventStamp Stamp, + RuntimePlacementProjectionSnapshot Placement); + public enum RuntimeInventoryChange { Added, diff --git a/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs b/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs index 95ccf9dc..3df6294c 100644 --- a/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs +++ b/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs @@ -9,6 +9,20 @@ public readonly record struct RuntimePhysicsOwnershipSnapshot( int SpatialRootCount, int SpatialRemoteCount, int SpatialProjectileCount, + int SetPositionOperationCount, + int AwaitingSetPositionPreparationCount, + int DeferredSetPositionCount, + int PendingSetPositionHostAcknowledgementCount, + int LostCellDeadlineCount, + int LostCellDeadlineNodeCount, + int LostCellDeadlineIndexCount, + int ExpiredLostCellCount, + int ExpiredLostCellIndexCount, + int DeferredSetPositionBucketCount, + int DeferredSetPositionBucketOrderCount, + int UnboundDeferredSetPositionCellCount, + int UnboundDeferredSetPositionCellOrderCount, + int PreparedSetPositionMoverCount, int CollisionAdmissionCount, int CollisionGenerationCount, bool OwnsProductionDataCache, @@ -21,6 +35,20 @@ public readonly record struct RuntimePhysicsOwnershipSnapshot( && SpatialRootCount == 0 && SpatialRemoteCount == 0 && SpatialProjectileCount == 0 + && SetPositionOperationCount == 0 + && AwaitingSetPositionPreparationCount == 0 + && DeferredSetPositionCount == 0 + && PendingSetPositionHostAcknowledgementCount == 0 + && LostCellDeadlineCount == 0 + && LostCellDeadlineNodeCount == 0 + && LostCellDeadlineIndexCount == 0 + && ExpiredLostCellCount == 0 + && ExpiredLostCellIndexCount == 0 + && DeferredSetPositionBucketCount == 0 + && DeferredSetPositionBucketOrderCount == 0 + && UnboundDeferredSetPositionCellCount == 0 + && UnboundDeferredSetPositionCellOrderCount == 0 + && PreparedSetPositionMoverCount == 0 && CollisionAdmissionCount == 0 && CollisionGenerationCount == 0 && OwnsProductionDataCache; @@ -979,6 +1007,7 @@ internal readonly record struct RuntimeCollisionSealStep( public sealed class RuntimePhysicsState : IDisposable { private readonly TimeProvider _timeProvider; + private readonly IGameRuntimeClock? _gameClock; private readonly Dictionary _spatialRoots = new(); private readonly Dictionary @@ -1020,10 +1049,12 @@ public sealed class RuntimePhysicsState : IDisposable internal RuntimePhysicsState( RuntimeEntityDirectory entities, PhysicsDataCache? dataCache = null, - TimeProvider? timeProvider = null) + TimeProvider? timeProvider = null, + IGameRuntimeClock? gameClock = null) { Entities = entities ?? throw new ArgumentNullException(nameof(entities)); _timeProvider = timeProvider ?? TimeProvider.System; + _gameClock = gameClock; DataCache = dataCache ?? PhysicsDataCache.CreateProduction(); Engine = new PhysicsEngine { @@ -1032,15 +1063,18 @@ public sealed class RuntimePhysicsState : IDisposable Engine.ShadowObjects.OwnerMutated += OnCollisionOwnerMutated; Engine.ShadowObjects.OwnerPrefixMembershipChanged += OnCollisionOwnerPrefixMembershipChanged; + SetPosition = new RuntimeSetPositionState(this, Entities); } internal RuntimePhysicsState( RuntimeEntityDirectory entities, PhysicsEngine engine, - TimeProvider? timeProvider = null) + TimeProvider? timeProvider = null, + IGameRuntimeClock? gameClock = null) { Entities = entities ?? throw new ArgumentNullException(nameof(entities)); _timeProvider = timeProvider ?? TimeProvider.System; + _gameClock = gameClock; Engine = engine ?? throw new ArgumentNullException(nameof(engine)); DataCache = engine.DataCache ?? PhysicsDataCache.CreateProduction(engine.CollisionWorld); @@ -1048,11 +1082,13 @@ public sealed class RuntimePhysicsState : IDisposable Engine.ShadowObjects.OwnerMutated += OnCollisionOwnerMutated; Engine.ShadowObjects.OwnerPrefixMembershipChanged += OnCollisionOwnerPrefixMembershipChanged; + SetPosition = new RuntimeSetPositionState(this, Entities); } internal RuntimeEntityDirectory Entities { get; } public PhysicsEngine Engine { get; } public PhysicsDataCache DataCache { get; } + internal RuntimeSetPositionState SetPosition { get; } public int SpatialRootCount => _spatialRoots.Count; public int SpatialRemoteCount => _spatialRemotes.Count; public int SpatialProjectileCount => _spatialProjectiles.Count; @@ -1061,18 +1097,41 @@ public sealed class RuntimePhysicsState : IDisposable internal double UtcNowSeconds => (_timeProvider.GetUtcNow() - DateTimeOffset.UnixEpoch) .TotalSeconds; + internal double MonotonicNowSeconds => + _timeProvider.GetTimestamp() + / (double)_timeProvider.TimestampFrequency; + internal double PlacementSimulationTime(double fallback) => + _gameClock?.SimulationTimeSeconds ?? fallback; - public RuntimePhysicsOwnershipSnapshot CaptureOwnership() => - new( + public RuntimePhysicsOwnershipSnapshot CaptureOwnership() + { + RuntimeSetPositionOwnershipSnapshot setPosition = + SetPosition.CaptureOwnership(); + return new( Engine.LandblockCount, Engine.ShadowObjects.RetainedRegistrationCount, _spatialRoots.Count, _spatialRemotes.Count, _spatialProjectiles.Count, + setPosition.ActiveOperationCount, + setPosition.AwaitingPreparationCount, + setPosition.DeferredCellCount, + setPosition.PendingProjectionAcknowledgementCount, + setPosition.LostDeadlineCount, + setPosition.LostDeadlineNodeCount, + setPosition.LostDeadlineIndexCount, + setPosition.ExpiredLostCellCount, + setPosition.ExpiredLostCellIndexCount, + setPosition.DeferredBucketCount, + setPosition.DeferredBucketOrderCount, + setPosition.UnboundDeferredCellCount, + setPosition.UnboundDeferredCellOrderCount, + setPosition.PreparedMoverCount, _collisionAdmissions.Count, _collisionGenerations.Count, ReferenceEquals(Engine.DataCache, DataCache), _disposed); + } public void AcknowledgeSpatialProjection( RuntimeEntityRecord record, @@ -1806,6 +1865,20 @@ public sealed class RuntimePhysicsState : IDisposable EnsureNotDisposed(); EnsureCollisionMutationThread(); uint canonical = CanonicalLandblock(landblockId); + if (_collisionAdmissions.Remove( + canonical, + out RuntimeCollisionAdmission? superseded)) + { + SetPosition.CancelCollisionGeneration( + canonical, + superseded.Generation); + if (_preparedCollisionGenerations.Remove( + canonical, + out PreparedLandblockCollisionGeneration? prepared)) + { + prepared.Dispose(); + } + } ulong generation = _collisionGenerations.TryGetValue( canonical, out ulong current) @@ -1817,6 +1890,7 @@ public sealed class RuntimePhysicsState : IDisposable canonical, generation); _collisionAdmissions[canonical] = admission; + SetPosition.BeginCollisionGeneration(canonical, generation); return admission; } @@ -1901,6 +1975,9 @@ public sealed class RuntimePhysicsState : IDisposable && ReferenceEquals(current, admission)) { _collisionAdmissions.Remove(admission.LandblockId); + SetPosition.CancelCollisionGeneration( + admission.LandblockId, + admission.Generation); _collisionGenerations[admission.LandblockId] = checked( admission.Generation + 1UL); } @@ -2137,6 +2214,10 @@ public sealed class RuntimePhysicsState : IDisposable admission.Generation, Engine.IsLandblockTerrainResident(admission.LandblockId), Ready: Engine.IsLandblockTerrainResident(admission.LandblockId)); + SetPosition.CommitCollisionGeneration( + acknowledgement.LandblockId, + acknowledgement.Generation, + acknowledgement.Ready); PublishCollisionGenerationCommitted( new RuntimeCollisionGenerationCommitted( acknowledgement.LandblockId, @@ -2221,6 +2302,7 @@ public sealed class RuntimePhysicsState : IDisposable prepared.Dispose(); } _preparedCollisionGenerations.Clear(); + SetPosition.Dispose(); _collisionOwnerJournal.Clear(); _collisionOwnerSubscribers.Clear(); Engine.Clear(); @@ -2380,12 +2462,22 @@ public sealed class RuntimePhysicsState : IDisposable private void InvalidateCollisionAdmission(uint landblockId) { - ulong generation = _collisionGenerations.TryGetValue( + ulong currentGeneration = _collisionGenerations.TryGetValue( landblockId, out ulong current) - ? checked(current + 1UL) - : 1UL; + ? current + : 0UL; + ulong invalidatedGeneration = _collisionAdmissions.TryGetValue( + landblockId, + out RuntimeCollisionAdmission? admission) + ? admission.Generation + : checked(currentGeneration + 1UL); + ulong generation = checked( + Math.Max(currentGeneration, invalidatedGeneration) + 1UL); _collisionGenerations[landblockId] = generation; + SetPosition.CancelCollisionGeneration( + landblockId, + invalidatedGeneration); _collisionAdmissions.Remove(landblockId); if (_preparedCollisionGenerations.Remove( landblockId, @@ -2396,6 +2488,70 @@ public sealed class RuntimePhysicsState : IDisposable TrimCollisionOwnerJournal(); } + internal ulong ExpectedCollisionGeneration(uint exactCellId) + { + uint landblockId = CanonicalLandblock(exactCellId); + if (landblockId == 0u) + return 0UL; + if (_collisionAdmissions.TryGetValue( + landblockId, + out RuntimeCollisionAdmission? admission)) + { + return admission.Generation; + } + return _collisionGenerations.TryGetValue( + landblockId, + out ulong generation) + ? checked(generation + 1UL) + : 1UL; + } + + internal bool HandleSetPositionCollisions( + RuntimeEntityRecord record, + ulong positionAuthorityVersion, + ulong spatialAuthorityVersion, + ulong velocityAuthorityVersion, + bool previousContact, + bool previousOnWalkable, + in PhysicsSetPositionCollisionReport report) + { + if (!Entities.IsCurrent(record) + || record.PositionAuthorityVersion != positionAuthorityVersion + || record.SpatialAuthorityVersion != spatialAuthorityVersion + || (velocityAuthorityVersion != 0UL + && record.VelocityAuthorityVersion + != velocityAuthorityVersion) + || record.PhysicsBody is not { } body) + { + return false; + } + + body.FramesStationaryFall = report.FramesStationaryFall; + PhysicsObjUpdate.HandleAllCollisions( + body, + report.CollisionNormalValid, + report.CollisionNormal, + previousContact, + previousOnWalkable, + body.OnWalkable); + body.TransientState &= ~(TransientStateFlags.StationaryFall + | TransientStateFlags.StationaryStop + | TransientStateFlags.StationaryStuck); + body.TransientState |= report.FramesStationaryFall switch + { + 1 => TransientStateFlags.StationaryFall, + 2 => TransientStateFlags.StationaryStop, + 3 => TransientStateFlags.StationaryStuck, + _ => TransientStateFlags.None, + }; + // Retail returns the result of collision reporting, not a collision- + // presence guess. Runtime does not yet own the per-object report/ + // tracking table required to reproduce that return value, so fail + // closed. This preserves ordinary placement rejection and leaves the + // already-registered reporting seam explicit for the 4B2 cutover. + return false; + } + private void OnCollisionOwnerMutated(uint ownerId, ulong version) { _ = version; diff --git a/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs b/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs new file mode 100644 index 00000000..a4381184 --- /dev/null +++ b/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs @@ -0,0 +1,2014 @@ +using System.Collections.Immutable; +using System.Numerics; +using AcDream.Core.Physics; +using AcDream.Runtime.Entities; + +namespace AcDream.Runtime.Physics; + +internal enum RuntimeSetPositionOperationKind +{ + InitialLogin, + LocalAuthoritative, + RemoteAuthoritative, + ProjectileAuthoritative, +} + +internal enum RuntimeSetPositionStatus +{ + Rejected, + DeferredCell, + CommittedHostAcknowledgementPending, + Committed, + Cancelled, +} + +internal enum RuntimeEntityPlacementStage +{ + AwaitingPreparation, + AwaitingWithdrawalAcknowledgement, + AwaitingCell, + AwaitingCommitAcknowledgement, + CancelledAwaitingAcknowledgement, +} + +internal readonly record struct RuntimeEntityPlacementToken( + ulong SessionLifetimeVersion, + RuntimeEntityKey Entity, + ulong PositionAuthorityVersion, + ulong OperationId) +{ + internal bool IsValid => OperationId != 0UL + && Entity.LocalEntityId != 0u + && PositionAuthorityVersion != 0UL; +} + +public enum RuntimePlacementProjectionKind +{ + Withdraw, + Place, + Discard, +} + +public readonly record struct RuntimePortalPlacementAuthority( + bool Present, + long RevealGeneration, + ushort TeleportSequence, + RuntimeWorldHostProjectionToken Projection) +{ + internal bool IsEmpty => !Present + && RevealGeneration == 0 + && TeleportSequence == 0 + && Projection == default; + internal bool IsValid => Present + && RevealGeneration != 0 + && Projection.IsValid + && Projection.Generation == RevealGeneration; +} + +internal readonly record struct RuntimeSetPositionCommand( + PhysicsSetPositionRequest Physics, + RuntimeSetPositionOperationKind Kind, + double GameTime, + ulong ExpectedVelocityAuthorityVersion, + float ShadowWorldOffsetX = 0f, + float ShadowWorldOffsetY = 0f, + RuntimePortalPlacementAuthority Portal = default); + +public readonly record struct RuntimePlacementProjectionToken( + ulong Sequence, + ulong Revision, + RuntimeEntityKey Entity, + ulong PositionAuthorityVersion, + ulong SpatialAuthorityVersion, + ulong PlacementCommitVersion, + ulong SessionLifetimeVersion, + uint ExactCellId, + ulong CollisionGeneration, + RuntimePortalPlacementAuthority Portal) +{ + internal bool IsValid => Sequence != 0 + && Entity.LocalEntityId != 0u + && PositionAuthorityVersion != 0UL; +} + +public readonly record struct RuntimePlacementProjectionSnapshot( + RuntimePlacementProjectionToken Token, + RuntimePlacementProjectionKind Kind, + Vector3 WorldPosition, + Quaternion Orientation, + Vector3 CellLocalPosition, + bool InContact, + bool OnWalkable); + +internal readonly record struct RuntimeSetPositionOutcome( + RuntimeSetPositionStatus Status, + PhysicsSetPositionError Error, + PhysicsResidenceDisposition Residence, + uint ExactCellId, + RuntimePlacementProjectionToken Projection) +{ + internal bool Accepted => Error == PhysicsSetPositionError.Ok; +} + +internal readonly record struct RuntimePlacementCancellationReceipt( + RuntimePlacementProjectionSnapshot Projection) +{ + internal bool IsValid => Projection.Kind + is RuntimePlacementProjectionKind.Discard + && Projection.Token.IsValid; +} + +internal readonly record struct RuntimeSetPositionOwnershipSnapshot( + int ActiveOperationCount, + int AwaitingPreparationCount, + int DeferredCellCount, + int PendingProjectionAcknowledgementCount, + int LostDeadlineCount, + int LostDeadlineNodeCount, + int LostDeadlineIndexCount, + int ExpiredLostCellCount, + int ExpiredLostCellIndexCount, + int DeferredBucketCount, + int DeferredBucketOrderCount, + int UnboundDeferredCellCount, + int UnboundDeferredCellOrderCount, + int PreparedMoverCount) +{ + internal bool IndexesConsistent => + LostDeadlineCount == LostDeadlineNodeCount + && LostDeadlineCount == LostDeadlineIndexCount + && ExpiredLostCellCount == ExpiredLostCellIndexCount + && DeferredBucketCount == DeferredBucketOrderCount + && UnboundDeferredCellCount == UnboundDeferredCellOrderCount; + + internal bool IsConverged => ActiveOperationCount == 0 + && AwaitingPreparationCount == 0 + && DeferredCellCount == 0 + && PendingProjectionAcknowledgementCount == 0 + && LostDeadlineCount == 0 + && LostDeadlineNodeCount == 0 + && LostDeadlineIndexCount == 0 + && ExpiredLostCellCount == 0 + && ExpiredLostCellIndexCount == 0 + && DeferredBucketCount == 0 + && DeferredBucketOrderCount == 0 + && UnboundDeferredCellCount == 0 + && UnboundDeferredCellOrderCount == 0 + && PreparedMoverCount == 0; +} + +/// +/// Presentation-independent owner of retail SetPosition residence. Runtime +/// commits the complete Core packet before publishing an immutable projection +/// token. A host can retry the exact token until acknowledged; no App delegate +/// is retained and no presentation callback can duplicate simulation state. +/// +internal sealed class RuntimeSetPositionState : IDisposable +{ + // Retail accepts an unsigned num_tries and performs the attempts in one + // synchronous call. Runtime preparation is an authority boundary, so an + // untrusted uint.MaxValue must not turn that verbatim loop into a frame- + // thread denial of service. Authored retail callers use small counts; + // 64 preserves that shape while making the synchronous work finite. + private const uint MaxSynchronousScatterAttempts = 64u; + + private readonly record struct CellGenerationKey( + uint CellId, + ulong CollisionGeneration); + + private sealed class Operation + { + internal required RuntimeEntityRecord Record { get; init; } + internal PhysicsBody? Body { get; set; } + internal required RuntimeEntityPlacementToken Token { get; init; } + internal required RuntimeEntityKey Key { get; init; } + internal required ulong PositionAuthorityVersion { get; init; } + internal required ulong SessionLifetimeVersion { get; init; } + internal required ulong SourceSpatialAuthorityVersion { get; init; } + internal required ulong SourceVelocityAuthorityVersion { get; set; } + internal required bool PreviousContact { get; set; } + internal required bool PreviousOnWalkable { get; set; } + internal required RuntimeSetPositionCommand Command { get; set; } + internal PhysicsSetPositionResult Result { get; set; } + internal ulong SpatialAuthorityVersion { get; set; } + internal ulong PlacementCommitVersion { get; set; } + internal uint ExactCellId { get; set; } + internal ulong CollisionGeneration { get; set; } + internal bool WithdrawalAcknowledged { get; set; } + internal bool CollisionGenerationReady { get; set; } + internal ulong ProjectionSequence { get; set; } + internal bool WakeableLostCell { get; set; } + internal RuntimeEntityPlacementStage Stage { get; set; } + internal RuntimeSetPositionOperationKind Kind { get; init; } + internal RuntimePortalPlacementAuthority Portal { get; init; } + internal bool RequiresPreparation { get; set; } + internal bool Expired { get; set; } + internal List? LostFamilyKeys { get; set; } + internal bool InheritedLostDeadline { get; set; } + internal bool EnteringWorldFromCelllessResidence { get; set; } + internal RuntimeSetPositionCommand? PreparedCommandAwaitingWithdrawalAck + { + get; + set; + } + } + + private readonly RuntimePhysicsState _physics; + private readonly RuntimeEntityDirectory _entities; + private readonly Dictionary _operations = []; + private readonly Dictionary> + _deferredByCellGeneration = []; + private readonly SortedDictionary + _pendingProjection = []; + private readonly List _deferredBucketOrder = []; + private readonly Dictionary> + _unboundDeferredByCell = []; + private readonly List _unboundDeferredCellOrder = []; + private readonly Dictionary _lostDeadlines = []; + private readonly List _lostDeadlineNodes = []; + private readonly Dictionary + _lostDeadlineNodeIndex = []; + private readonly Dictionary + _preparedMovers = []; + private readonly LinkedList _expiredLostCells = []; + private readonly Dictionary> + _expiredLostCellNodes = []; + private ulong _nextProjectionSequence; + private ulong _nextOperationId; + private ulong _nextLostDeadlineSequence; + private RuntimeEntityObjectEventStream? _events; + private bool _disposed; + + private readonly record struct LostDeadlineEntry( + RuntimeEntityKey Key, + double Deadline, + ulong Sequence); + + internal RuntimeSetPositionState( + RuntimePhysicsState physics, + RuntimeEntityDirectory entities) + { + _physics = physics ?? throw new ArgumentNullException(nameof(physics)); + _entities = entities ?? throw new ArgumentNullException(nameof(entities)); + } + + internal RuntimeSetPositionOwnershipSnapshot CaptureOwnership() + { + int deferred = 0; + int awaitingPreparation = 0; + foreach (Operation operation in _operations.Values) + { + if (operation.Stage + is RuntimeEntityPlacementStage.AwaitingPreparation) + { + awaitingPreparation++; + } + if (operation.WakeableLostCell) + deferred++; + } + return new RuntimeSetPositionOwnershipSnapshot( + _operations.Count, + awaitingPreparation, + deferred, + _pendingProjection.Count, + _lostDeadlines.Count, + _lostDeadlineNodes.Count, + _lostDeadlineNodeIndex.Count, + _expiredLostCells.Count, + _expiredLostCellNodes.Count, + _deferredByCellGeneration.Count, + _deferredBucketOrder.Count, + _unboundDeferredByCell.Count, + _unboundDeferredCellOrder.Count, + _preparedMovers.Count); + } + + internal void BindEventStream(RuntimeEntityObjectEventStream events) + { + EnsureNotDisposed(); + ArgumentNullException.ThrowIfNull(events); + if (_events is not null) + throw new InvalidOperationException( + "The Runtime placement event stream is already bound."); + _events = events; + } + + internal void RetryPendingProjections() + { + EnsureNotDisposed(); + RuntimePlacementProjectionSnapshot[] snapshot = + _pendingProjection.Values.ToArray(); + for (int index = 0; index < snapshot.Length; index++) + { + RuntimePlacementProjectionSnapshot projection = snapshot[index]; + if (_pendingProjection.TryGetValue( + projection.Token.Sequence, + out RuntimePlacementProjectionSnapshot current) + && current == projection) + { + PublishPlacement(projection); + } + } + } + + internal void ResetSession() + { + EnsureNotDisposed(); + ClearOwnedState(); + } + + internal RuntimeSetPositionOutcome Apply( + RuntimeEntityRecord record, + ulong expectedPositionAuthorityVersion, + in RuntimeSetPositionCommand command) + { + EnsureNotDisposed(); + ArgumentNullException.ThrowIfNull(record); + RuntimeEntityPlacementToken token = BeginAcceptedPlacement( + record, + expectedPositionAuthorityVersion, + command.Kind, + command.Portal); + if (!token.IsValid) + { + return Rejected(command.Physics); + } + + return SubmitPreparedPlacement(token, command); + } + + internal RuntimeEntityPlacementToken BeginAcceptedPlacement( + RuntimeEntityRecord record, + ulong expectedPositionAuthorityVersion, + RuntimeSetPositionOperationKind kind, + RuntimePortalPlacementAuthority portal = default) + { + EnsureNotDisposed(); + ArgumentNullException.ThrowIfNull(record); + if (record.Key is not { } key + || !_entities.IsCurrent(record) + || record.PositionAuthorityVersion + != expectedPositionAuthorityVersion + || !(portal.IsEmpty + || (portal.IsValid + && kind is RuntimeSetPositionOperationKind + .LocalAuthoritative))) + { + return default; + } + + var token = new RuntimeEntityPlacementToken( + _entities.SessionLifetimeVersion, + key, + expectedPositionAuthorityVersion, + checked(++_nextOperationId)); + var replacement = new Operation + { + Record = record, + Token = token, + Key = key, + PositionAuthorityVersion = expectedPositionAuthorityVersion, + SessionLifetimeVersion = _entities.SessionLifetimeVersion, + SourceSpatialAuthorityVersion = record.SpatialAuthorityVersion, + SourceVelocityAuthorityVersion = record.VelocityAuthorityVersion, + PreviousContact = record.PhysicsBody?.InContact ?? false, + PreviousOnWalkable = record.PhysicsBody?.OnWalkable ?? false, + Command = default, + Result = default, + SpatialAuthorityVersion = record.SpatialAuthorityVersion, + PlacementCommitVersion = record.PlacementCommitVersion, + Stage = RuntimeEntityPlacementStage.AwaitingPreparation, + Kind = kind, + Portal = portal, + }; + List? inheritedLostFamily = null; + RuntimePlacementProjectionSnapshot? inheritedWithdrawal = null; + bool inheritedWithdrawalAcknowledged = false; + if (_operations.TryGetValue(key, out Operation? displaced) + && (displaced.WakeableLostCell + || displaced.InheritedLostDeadline)) + { + inheritedLostFamily = displaced.LostFamilyKeys; + displaced.LostFamilyKeys = null; + inheritedWithdrawalAcknowledged = + displaced.WithdrawalAcknowledged; + if (displaced.ProjectionSequence != 0UL + && _pendingProjection.TryGetValue( + displaced.ProjectionSequence, + out RuntimePlacementProjectionSnapshot pendingWithdrawal) + && pendingWithdrawal.Kind + is RuntimePlacementProjectionKind.Withdraw) + { + inheritedWithdrawal = pendingWithdrawal; + displaced.ProjectionSequence = 0UL; + } + } + _ = CancelCoreDeferred( + key, + cancelLostFamily: false, + preserveLostFamily: inheritedLostFamily is not null, + out RuntimePlacementProjectionSnapshot? discard); + replacement.LostFamilyKeys = inheritedLostFamily; + replacement.InheritedLostDeadline = inheritedLostFamily is not null; + replacement.WithdrawalAcknowledged = inheritedWithdrawalAcknowledged; + if (inheritedWithdrawal is { } retainedWithdrawal) + { + replacement.ProjectionSequence = + retainedWithdrawal.Token.Sequence; + replacement.Result = displaced!.Result; + replacement.ExactCellId = displaced.ExactCellId; + replacement.CollisionGeneration = + displaced.CollisionGeneration; + } + _operations[key] = replacement; + if (discard is { } cancelled) + PublishPlacement(cancelled); + return IsCurrent(replacement) ? token : default; + } + + internal RuntimeSetPositionOutcome SubmitPreparedPlacement( + in RuntimeEntityPlacementToken token, + in RuntimeSetPositionCommand command) + { + EnsureNotDisposed(); + Operation? operation = null; + bool ownsToken = token.IsValid + && _operations.TryGetValue(token.Entity, out operation) + && operation.Token == token; + if (!ownsToken + || operation is null + || operation.Stage + is not RuntimeEntityPlacementStage.AwaitingPreparation + || !IsCurrent(operation) + || operation.Record.PhysicsBody is not { } body + || !double.IsFinite(command.GameTime) + || command.Kind != operation.Kind + || command.Portal != operation.Portal + || (command.ExpectedVelocityAuthorityVersion != 0UL + && operation.Record.VelocityAuthorityVersion + != command.ExpectedVelocityAuthorityVersion)) + { + // Preparation is an accepted-operation input, not the operation + // itself. A malformed or temporarily incomplete preparation must + // remain retryable under the exact token and cannot discard a + // withdrawn resident or its last validated mover shape. + return Rejected(command.Physics); + } + + operation.Body = body; + operation.EnteringWorldFromCelllessResidence |= + !body.InWorld || operation.Record.FullCellId == 0u; + if (command.ExpectedVelocityAuthorityVersion != 0UL) + { + operation.SourceVelocityAuthorityVersion = + command.ExpectedVelocityAuthorityVersion; + } + operation.PreviousContact = body.InContact; + operation.PreviousOnWalkable = body.OnWalkable; + var canonicalRequest = command.Physics with + { + Position = operation.WakeableLostCell + ? operation.Result.Position + : command.Physics.Position, + Orientation = operation.WakeableLostCell + ? operation.Result.Orientation + : command.Physics.Orientation, + CellId = operation.WakeableLostCell + ? operation.ExactCellId + : command.Physics.CellId, + CellLocalPosition = operation.WakeableLostCell + ? operation.Result.CellLocalPosition + : command.Physics.CellLocalPosition, + MoverPhysicsState = operation.Record.FinalPhysicsState, + MovingEntityId = operation.Key.LocalEntityId, + CurrentCellId = operation.WakeableLostCell + ? null + : body.InWorld + && operation.Record.FullCellId != 0u + ? operation.Record.FullCellId + : null, + }; + var canonicalCommand = command with { Physics = canonicalRequest }; + operation.Command = canonicalCommand; + + if (operation.InheritedLostDeadline + && !operation.WithdrawalAcknowledged + && operation.ProjectionSequence != 0UL) + { + operation.PreparedCommandAwaitingWithdrawalAck = + canonicalCommand; + operation.Stage = RuntimeEntityPlacementStage + .AwaitingWithdrawalAcknowledgement; + return Outcome( + RuntimeSetPositionStatus.DeferredCell, + operation.Result, + _pendingProjection.TryGetValue( + operation.ProjectionSequence, + out RuntimePlacementProjectionSnapshot pending) + ? pending.Token + : default); + } + + if (operation.WakeableLostCell) + { + operation.RequiresPreparation = false; + operation.Stage = RuntimeEntityPlacementStage.AwaitingCell; + if (operation.WithdrawalAcknowledged + && operation.CollisionGenerationReady + && operation.ProjectionSequence == 0UL) + { + RetryDeferred(operation); + } + return Outcome( + RuntimeSetPositionStatus.DeferredCell, + operation.Result, + operation.ProjectionSequence != 0UL + && _pendingProjection.TryGetValue( + operation.ProjectionSequence, + out RuntimePlacementProjectionSnapshot pending) + ? pending.Token + : default); + } + + if (!IsStructurallyValid(canonicalRequest)) + { + PhysicsSetPositionResult invalid = InvalidResult(canonicalRequest); + operation.Result = invalid; + operation.RequiresPreparation = true; + operation.Stage = RuntimeEntityPlacementStage.AwaitingPreparation; + return Outcome(RuntimeSetPositionStatus.Rejected, invalid, default); + } + + PhysicsSetPositionResult result = + _physics.Engine.SetPosition( + canonicalRequest, + report => _physics.HandleSetPositionCollisions( + operation.Record, + operation.PositionAuthorityVersion, + operation.SourceSpatialAuthorityVersion, + operation.SourceVelocityAuthorityVersion, + operation.PreviousContact, + operation.PreviousOnWalkable, + report)); + operation.Result = result; + if (!result.IsSuccessful) + { + operation.RequiresPreparation = true; + operation.Stage = RuntimeEntityPlacementStage.AwaitingPreparation; + return Outcome(RuntimeSetPositionStatus.Rejected, result, default); + } + + if (!IsCurrent(operation)) + return Outcome(RuntimeSetPositionStatus.Cancelled, result, default); + operation.RequiresPreparation = false; + operation.ExactCellId = result.CellId; + _preparedMovers[operation.Key] = canonicalRequest; + + if (result.IsDeferred) + return ParkDeferred(operation, result); + + if (!CommitCanonical(operation, result)) + { + PublishCancellation(CancelCore(operation)); + return Outcome(RuntimeSetPositionStatus.Cancelled, result, default); + } + + operation.Stage = RuntimeEntityPlacementStage + .AwaitingCommitAcknowledgement; + RuntimePlacementProjectionToken projection = PublishProjection( + operation, + RuntimePlacementProjectionKind.Place, + result); + return Outcome( + RuntimeSetPositionStatus.CommittedHostAcknowledgementPending, + result, + projection); + } + + internal bool TryPeekProjection( + out RuntimePlacementProjectionSnapshot projection) + { + EnsureNotDisposed(); + if (_pendingProjection.Count == 0) + { + projection = default; + return false; + } + projection = _pendingProjection.First().Value; + return true; + } + + internal bool AcknowledgeProjection( + in RuntimePlacementProjectionToken token) + { + EnsureNotDisposed(); + if (!token.IsValid + || _pendingProjection.Count == 0 + || _pendingProjection.First().Key != token.Sequence + || !_pendingProjection.TryGetValue( + token.Sequence, + out RuntimePlacementProjectionSnapshot pending) + || pending.Token != token) + { + return false; + } + if (pending.Kind is RuntimePlacementProjectionKind.Discard) + { + _pendingProjection.Remove(token.Sequence); + return true; + } + if (!_operations.TryGetValue( + token.Entity, + out Operation? operation) + || operation.ProjectionSequence != token.Sequence + || !IsCurrent(operation) + || operation.SpatialAuthorityVersion + != token.SpatialAuthorityVersion) + { + return false; + } + + _pendingProjection.Remove(token.Sequence); + operation.ProjectionSequence = 0UL; + if (pending.Kind is RuntimePlacementProjectionKind.Place) + { + operation.Stage = RuntimeEntityPlacementStage.AwaitingCommitAcknowledgement; + return _operations.Remove(operation.Key); + } + if (!operation.WakeableLostCell + && !operation.InheritedLostDeadline) + { + return _operations.Remove(operation.Key); + } + + operation.WithdrawalAcknowledged = true; + operation.Stage = operation.RequiresPreparation + || operation.InheritedLostDeadline + ? RuntimeEntityPlacementStage.AwaitingPreparation + : RuntimeEntityPlacementStage.AwaitingCell; + if (operation.PreparedCommandAwaitingWithdrawalAck is { } prepared) + { + operation.PreparedCommandAwaitingWithdrawalAck = null; + operation.Stage = RuntimeEntityPlacementStage.AwaitingPreparation; + _ = SubmitPreparedPlacement(operation.Token, prepared); + return true; + } + if (operation.CollisionGenerationReady) + RetryDeferred(operation); + return true; + } + + /// + /// Runs retail's 25-second lost-cell destruction timer. Expired exact + /// incarnation keys are consumed by the entity-lifetime owner; no host + /// callback or GUID-only deletion can cross a replacement generation. + /// + internal void TickLostCellDeadlines() + { + EnsureNotDisposed(); + if (_lostDeadlineNodes.Count == 0) + return; + double now = _physics.MonotonicNowSeconds; + while (_lostDeadlineNodes.Count != 0) + { + LostDeadlineEntry entry = _lostDeadlineNodes[0]; + if (entry.Deadline > now) + break; + RemoveLostDeadlineNodeAt(0); + if (!_lostDeadlines.Remove(entry.Key)) + throw new InvalidOperationException( + "The lost-cell deadline index diverged from its exact key owner."); + if (_operations.TryGetValue( + entry.Key, + out Operation? operation)) + operation.Expired = true; + if (_entities.TryGetByLocalId( + entry.Key.LocalEntityId, + out RuntimeEntityRecord record) + && record.Key == entry.Key) + { + if (!_expiredLostCellNodes.ContainsKey(entry.Key)) + { + LinkedListNode node = + _expiredLostCells.AddLast(entry.Key); + _expiredLostCellNodes.Add(entry.Key, node); + } + } + } + } + + internal bool TryDequeueExpiredLostCell(out RuntimeEntityKey key) + { + EnsureNotDisposed(); + if (_expiredLostCells.First is not { } first) + { + key = default; + return false; + } + key = first.Value; + _expiredLostCells.RemoveFirst(); + _expiredLostCellNodes.Remove(key); + return true; + } + + internal bool Cancel(RuntimeEntityRecord record, bool publishWithdrawal) + { + EnsureNotDisposed(); + ArgumentNullException.ThrowIfNull(record); + if (record.Key is not { } key) + return false; + + bool removed = CancelCoreDeferred( + key, + cancelLostFamily: false, + preserveLostFamily: false, + out RuntimePlacementProjectionSnapshot? discard); + if (!publishWithdrawal || !_entities.IsCurrent(record)) + { + if (discard is { } cancelled) + PublishPlacement(cancelled); + return removed; + } + + LeaveWorldCanonical(record); + if (record.PhysicsBody is null) + { + if (discard is { } cancelledBodyless) + PublishPlacement(cancelledBodyless); + return removed; + } + var operation = CreateWithdrawalOperation(record, key); + _operations[key] = operation; + if (discard is { } cancelledOld) + PublishPlacement(cancelledOld); + if (!IsCurrent(operation)) + return true; + _ = PublishProjection( + operation, + RuntimePlacementProjectionKind.Withdraw, + operation.Result); + return true; + } + + internal RuntimePlacementCancellationReceipt Forget( + RuntimeEntityRecord record, + bool releasePreparedMover = false) + { + EnsureNotDisposed(); + ArgumentNullException.ThrowIfNull(record); + RuntimePlacementCancellationReceipt receipt = default; + if (record.Key is { } key) + { + receipt = CancelCore(key); + if (releasePreparedMover) + _preparedMovers.Remove(key); + } + return receipt; + } + + internal void LeaveWorld(RuntimeEntityRecord record) + { + EnsureNotDisposed(); + ArgumentNullException.ThrowIfNull(record); + RuntimePlacementCancellationReceipt receipt = record.Key is { } key + ? CancelCore(key) + : default; + if (_entities.IsCurrent(record)) + LeaveWorldCanonical(record); + PublishCancellation(receipt); + } + + internal bool IsDeferred(RuntimeEntityRecord record) => + record.Key is { } key + && _operations.TryGetValue(key, out Operation? operation) + && ReferenceEquals(operation.Record, record) + && operation.WakeableLostCell; + + internal bool TryGetPreparedMoverSphereCount( + RuntimeEntityRecord record, + out int sphereCount) + { + EnsureNotDisposed(); + if (record.Key is { } key + && _preparedMovers.TryGetValue( + key, + out PhysicsSetPositionRequest request)) + { + sphereCount = request.Spheres.Length; + return true; + } + sphereCount = 0; + return false; + } + + internal bool TryGetAwaitingPreparationToken( + RuntimeEntityRecord record, + out RuntimeEntityPlacementToken token) + { + EnsureNotDisposed(); + if (record.Key is { } key + && _operations.TryGetValue(key, out Operation? operation) + && ReferenceEquals(operation.Record, record) + && operation.RequiresPreparation) + { + token = operation.Token; + return true; + } + token = default; + return false; + } + + internal void ParkCollisionResidents( + uint landblockId, + bool includeOutdoorCells) + { + EnsureNotDisposed(); + uint prefix = landblockId & 0xFFFF0000u; + var roots = new List(); + _physics.CopySpatialRootsTo(roots); + for (int index = 0; index < roots.Count; index++) + { + RuntimeEntityRecord record = roots[index]; + if (IsAffectedCollisionResident( + record, + prefix, + includeOutdoorCells) + && record.Key is { } key + && _operations.ContainsKey(key)) + { + throw new InvalidOperationException( + $"Collision retirement for 0x{prefix:X8} cannot overlap active placement for 0x{record.ServerGuid:X8}/{record.Incarnation}."); + } + } + var stagedWithdrawals = new List( + roots.Count); + for (int index = 0; index < roots.Count; index++) + { + RuntimeEntityRecord record = roots[index]; + uint cellId = record.FullCellId; + if (!IsAffectedCollisionResident( + record, + prefix, + includeOutdoorCells) + || record.Key is not { } key + || record.PhysicsBody is not { } body) + { + continue; + } + + PublishCancellation(CancelCore(key)); + bool hasPrepared = _preparedMovers.TryGetValue( + key, + out PhysicsSetPositionRequest prepared); + var request = (hasPrepared + ? prepared + : new PhysicsSetPositionRequest( + body.Position, + body.Orientation, + cellId, + body.CellPosition.Frame.Origin, + ImmutableArray.Empty, + 1f, + 0f, + 0f)) with + { + Position = body.Position, + Orientation = body.Orientation, + CellId = cellId, + CellLocalPosition = body.CellPosition.Frame.Origin, + MoverPhysicsState = record.FinalPhysicsState, + MovingEntityId = key.LocalEntityId, + CurrentCellId = null, + }; + var result = new PhysicsSetPositionResult( + PhysicsSetPositionError.Ok, + PhysicsResidenceDisposition.DeferredCell, + body.Position, + body.Orientation, + cellId, + body.CellPosition.Frame.Origin, + InContact: body.InContact, + OnWalkable: body.OnWalkable, + ContactPlane: body.ContactPlane, + ContactPlaneCellId: body.ContactPlaneCellId, + ContactPlaneIsWater: body.ContactPlaneIsWater, + SlidingNormalValid: body.SlidingNormal != Vector3.Zero, + SlidingNormal: body.SlidingNormal, + FramesStationaryFall: body.FramesStationaryFall, + CrossCellIds: ImmutableArray.Empty, + CollidedObjectIds: ImmutableArray.Empty); + var command = new RuntimeSetPositionCommand( + request, + RuntimeSetPositionOperationKind.RemoteAuthoritative, + body.LastUpdateTime, + record.VelocityAuthorityVersion); + var operation = new Operation + { + Record = record, + Body = body, + Token = new RuntimeEntityPlacementToken( + _entities.SessionLifetimeVersion, + key, + record.PositionAuthorityVersion, + checked(++_nextOperationId)), + Key = key, + PositionAuthorityVersion = record.PositionAuthorityVersion, + SessionLifetimeVersion = _entities.SessionLifetimeVersion, + SourceSpatialAuthorityVersion = record.SpatialAuthorityVersion, + SourceVelocityAuthorityVersion = record.VelocityAuthorityVersion, + PreviousContact = body.InContact, + PreviousOnWalkable = body.OnWalkable, + Command = command, + Result = result, + SpatialAuthorityVersion = record.SpatialAuthorityVersion, + PlacementCommitVersion = record.PlacementCommitVersion, + ExactCellId = cellId, + Stage = RuntimeEntityPlacementStage.AwaitingPreparation, + Kind = RuntimeSetPositionOperationKind.RemoteAuthoritative, + Portal = default, + RequiresPreparation = !hasPrepared, + }; + _operations.Add(key, operation); + RuntimeSetPositionOutcome parked = ParkDeferred( + operation, + result, + publishImmediately: false); + if (_pendingProjection.TryGetValue( + parked.Projection.Sequence, + out RuntimePlacementProjectionSnapshot staged)) + { + stagedWithdrawals.Add(staged); + } + if (operation.RequiresPreparation) + { + operation.Stage = RuntimeEntityPlacementStage + .AwaitingPreparation; + } + } + + // Canonical residence and operation ownership for every affected root + // must be installed before a synchronous host observer can re-enter. + // A callback may legitimately replace or cancel a later root; publish + // only the exact snapshot that is still pending when its turn arrives. + for (int index = 0; index < stagedWithdrawals.Count; index++) + { + RuntimePlacementProjectionSnapshot staged = + stagedWithdrawals[index]; + if (_pendingProjection.TryGetValue( + staged.Token.Sequence, + out RuntimePlacementProjectionSnapshot current) + && current == staged) + { + PublishPlacement(staged); + } + } + } + + private bool IsAffectedCollisionResident( + RuntimeEntityRecord record, + uint prefix, + bool includeOutdoorCells) + { + uint cellId = record.FullCellId; + return (cellId & 0xFFFF0000u) == prefix + && (includeOutdoorCells || (cellId & 0xFFFFu) >= 0x0100u) + && (record.FinalPhysicsState & PhysicsStateFlags.Static) == 0 + && !_entities.ParentAttachments.HasCommittedParent( + record.ServerGuid); + } + + internal void BeginCollisionGeneration(uint landblockId, ulong generation) + { + EnsureNotDisposed(); + if (generation == 0UL) + throw new ArgumentOutOfRangeException(nameof(generation)); + uint prefix = landblockId & 0xFFFF0000u; + for (int index = 0; index < _unboundDeferredCellOrder.Count;) + { + uint cellId = _unboundDeferredCellOrder[index]; + if ((cellId & 0xFFFF0000u) != prefix + || !_unboundDeferredByCell.Remove( + cellId, + out List? retained)) + { + index++; + continue; + } + _unboundDeferredCellOrder.RemoveAt(index); + var bucket = new CellGenerationKey(cellId, generation); + var rebound = new List(retained.Count); + for (int entityIndex = 0; + entityIndex < retained.Count; + entityIndex++) + { + RuntimeEntityKey entity = retained[entityIndex]; + if (_operations.TryGetValue(entity, out Operation? operation) + && operation.WakeableLostCell + && operation.ExactCellId == cellId + && operation.CollisionGeneration == 0UL) + { + operation.CollisionGeneration = generation; + rebound.Add(entity); + } + } + if (rebound.Count != 0) + { + if (_deferredByCellGeneration.TryGetValue( + bucket, + out List? futureBound)) + { + // The unbound survivors entered lost residence before + // entities indexed directly into this future generation. + // Preserve both append orders while restoring that age. + var merged = new List( + rebound.Count + futureBound.Count); + merged.AddRange(rebound); + merged.AddRange(futureBound); + _deferredByCellGeneration[bucket] = merged; + } + else + { + _deferredByCellGeneration.Add(bucket, rebound); + _deferredBucketOrder.Add(bucket); + } + } + } + foreach (Operation operation in _operations.Values) + { + if (!operation.WakeableLostCell + || operation.CollisionGeneration != 0UL + || (operation.ExactCellId & 0xFFFF0000u) != prefix) + { + continue; + } + operation.CollisionGeneration = generation; + IndexDeferred(operation); + } + } + + internal void CancelCollisionGeneration(uint landblockId, ulong generation) + { + EnsureNotDisposed(); + if (_deferredBucketOrder.Count == 0) + return; + uint prefix = landblockId & 0xFFFF0000u; + CellGenerationKey[] matching = _deferredBucketOrder + .Where(key => key.CollisionGeneration == generation + && (key.CellId & 0xFFFF0000u) == prefix) + .ToArray(); + for (int index = 0; index < matching.Length; index++) + { + UnbindDeferredBucket(matching[index]); + } + } + + internal void CommitCollisionGeneration( + uint landblockId, + ulong generation, + bool ready) + { + EnsureNotDisposed(); + if (!ready || _deferredBucketOrder.Count == 0) + return; + + uint prefix = landblockId & 0xFFFF0000u; + var cells = new List(); + for (int index = 0; index < _deferredBucketOrder.Count; index++) + { + CellGenerationKey key = _deferredBucketOrder[index]; + if (key.CollisionGeneration == generation + && (key.CellId & 0xFFFF0000u) == prefix) + { + cells.Add(key); + } + } + foreach (CellGenerationKey cell in cells) + { + if (!_deferredByCellGeneration.TryGetValue( + cell, + out List? indexed)) + { + continue; + } + RuntimeEntityKey[] exact = indexed.ToArray(); + if (!_physics.Engine.IsSpawnCellReady(cell.CellId)) + { + UnbindDeferredBucket(cell); + continue; + } + RemoveDeferredBucket(cell); + foreach (RuntimeEntityKey entity in exact) + { + if (!_operations.TryGetValue( + entity, + out Operation? operation) + || !operation.WakeableLostCell + || operation.ExactCellId != cell.CellId + || operation.CollisionGeneration != generation) + { + continue; + } + operation.CollisionGenerationReady = true; + if (operation.WithdrawalAcknowledged) + RetryDeferred(operation); + } + } + } + + public void Dispose() + { + if (_disposed) + return; + ClearOwnedState(); + _events = null; + _disposed = true; + } + + private void ClearOwnedState() + { + _operations.Clear(); + _deferredByCellGeneration.Clear(); + _deferredBucketOrder.Clear(); + _unboundDeferredByCell.Clear(); + _unboundDeferredCellOrder.Clear(); + _lostDeadlines.Clear(); + _lostDeadlineNodes.Clear(); + _lostDeadlineNodeIndex.Clear(); + _preparedMovers.Clear(); + _pendingProjection.Clear(); + _expiredLostCells.Clear(); + _expiredLostCellNodes.Clear(); + } + + private RuntimeSetPositionOutcome ParkDeferred( + Operation operation, + in PhysicsSetPositionResult result, + bool publishImmediately = true) + { + PhysicsBody body = operation.Body!; + body.Orientation = result.Orientation; + body.SnapToCell( + result.CellId, + result.Position, + result.CellLocalPosition); + body.InWorld = false; + body.TransientState &= ~TransientStateFlags.Active; + if (operation.Record.RemoteMotion is IRuntimeRemotePlacement remote) + { + remote.LastServerPosition = result.Position; + // Remote-motion stale-age bookkeeping is in Unix-time seconds + // (RuntimeRemotePhysicsUpdater and the retained App network + // updater both consume this field in that clock domain). The + // placement command's GameTime is the instance simulation clock + // used only by PhysicsBody.LastUpdateTime. Mixing the two after + // a deferred wake makes a fresh server velocity look decades old. + remote.LastServerPositionTime = _physics.UtcNowSeconds; + remote.LastShadowSyncPosition = Vector3.Zero; + remote.LastShadowSyncOrientation = Quaternion.Zero; + } + + WithdrawCanonical(operation.Record); + _entities.SuspendObjectClock(operation.Record); + operation.SpatialAuthorityVersion = + operation.Record.SpatialAuthorityVersion; + _entities.AdvancePlacementCommit(operation.Record); + operation.PlacementCommitVersion = + operation.Record.PlacementCommitVersion; + operation.ExactCellId = result.CellId; + operation.WakeableLostCell = true; + operation.EnteringWorldFromCelllessResidence = true; + ArmLostFamilyDeadlines(operation); + operation.CollisionGeneration = _physics + .ExpectedCollisionGeneration(result.CellId); + operation.Command = operation.Command with + { + Physics = operation.Command.Physics with + { + Position = result.Position, + Orientation = result.Orientation, + CellId = result.CellId, + CellLocalPosition = result.CellLocalPosition, + CurrentCellId = null, + }, + }; + IndexDeferred(operation); + operation.Stage = operation.RequiresPreparation + ? RuntimeEntityPlacementStage.AwaitingPreparation + : RuntimeEntityPlacementStage.AwaitingWithdrawalAcknowledgement; + RuntimePlacementProjectionToken projection = PublishProjection( + operation, + RuntimePlacementProjectionKind.Withdraw, + result, + publishImmediately); + return Outcome( + RuntimeSetPositionStatus.DeferredCell, + result, + projection); + } + + private void RetryDeferred(Operation operation) + { + if (!IsCurrent(operation) + || !operation.WakeableLostCell + || operation.RequiresPreparation + || operation.Expired + || !operation.WithdrawalAcknowledged + || !operation.CollisionGenerationReady + || operation.ProjectionSequence != 0UL) + { + return; + } + + UnindexDeferred(operation); + operation.Command = operation.Command with + { + GameTime = _physics.PlacementSimulationTime( + operation.Command.GameTime), + }; + PhysicsSetPositionResult result = IsStructurallyValid( + operation.Command.Physics) + ? _physics.Engine.SetPosition( + operation.Command.Physics, + report => _physics.HandleSetPositionCollisions( + operation.Record, + operation.PositionAuthorityVersion, + operation.SpatialAuthorityVersion, + operation.SourceVelocityAuthorityVersion, + operation.PreviousContact, + operation.PreviousOnWalkable, + report)) + : InvalidResult(operation.Command.Physics); + operation.CollisionGenerationReady = false; + if (result.IsDeferred) + { + operation.Result = result; + operation.ExactCellId = result.CellId; + _preparedMovers[operation.Key] = operation.Command.Physics; + operation.CollisionGeneration = _physics + .ExpectedCollisionGeneration(result.CellId); + IndexDeferred(operation); + return; + } + if (!result.IsSuccessful) + { + if (result.Error is PhysicsSetPositionError.InvalidArguments) + { + operation.RequiresPreparation = true; + operation.Stage = RuntimeEntityPlacementStage + .AwaitingPreparation; + operation.CollisionGeneration = _physics + .ExpectedCollisionGeneration(operation.ExactCellId); + IndexDeferred(operation); + } + else + { + operation.Stage = RuntimeEntityPlacementStage.AwaitingCell; + operation.CollisionGeneration = _physics + .ExpectedCollisionGeneration(operation.ExactCellId); + IndexDeferred(operation); + } + return; + } + if (!IsCurrent(operation)) + return; + _preparedMovers[operation.Key] = operation.Command.Physics; + if (!CommitCanonical(operation, result)) + { + PublishCancellation(CancelCore(operation)); + return; + } + + operation.Stage = RuntimeEntityPlacementStage + .AwaitingCommitAcknowledgement; + _ = PublishProjection( + operation, + RuntimePlacementProjectionKind.Place, + result); + } + + private bool CommitCanonical( + Operation operation, + in PhysicsSetPositionResult result) + { + if (!result.IsCommitted || !IsCurrent(operation)) + return false; + RuntimeEntityRecord record = operation.Record; + PhysicsBody body = operation.Body!; + body.Orientation = result.Orientation; + body.SnapToCell( + result.CellId, + result.Position, + result.CellLocalPosition); + bool isStatic = (record.FinalPhysicsState & PhysicsStateFlags.Static) != 0; + if (operation.EnteringWorldFromCelllessResidence) + { + body.LastUpdateTime = operation.Command.GameTime; + _entities.ResetObjectClockForEnterWorld(record, isStatic); + } + if (operation.EnteringWorldFromCelllessResidence && !isStatic) + body.TransientState |= TransientStateFlags.Active; + body.ContactPlaneValid = result.InContact; + body.ContactPlane = result.ContactPlane; + body.ContactPlaneCellId = result.ContactPlaneCellId; + body.ContactPlaneIsWater = result.ContactPlaneIsWater; + if (result.InContact) + body.GroundNormal = result.ContactPlane.Normal; + body.SlidingNormal = result.SlidingNormal; + if (result.SlidingNormalValid) + body.TransientState |= TransientStateFlags.Sliding; + else + body.TransientState &= ~TransientStateFlags.Sliding; + body.FramesStationaryFall = result.FramesStationaryFall; + body.TransientState &= ~(TransientStateFlags.StationaryFall + | TransientStateFlags.StationaryStop + | TransientStateFlags.StationaryStuck); + body.TransientState |= result.FramesStationaryFall switch + { + 1 => TransientStateFlags.StationaryFall, + 2 => TransientStateFlags.StationaryStop, + 3 => TransientStateFlags.StationaryStuck, + _ => TransientStateFlags.None, + }; + + IRuntimeRemotePlacement? remote = + record.RemoteMotion as IRuntimeRemotePlacement; + if (record.FullCellId != result.CellId) + { + _entities.SetFullCell( + record, + result.CellId, + (result.CellId & 0xFFFF0000u) | 0xFFFFu); + } + operation.SpatialAuthorityVersion = record.SpatialAuthorityVersion; + _entities.AdvancePlacementCommit(record); + operation.PlacementCommitVersion = record.PlacementCommitVersion; + ulong canonicalCommitVersion = record.PlacementCommitVersion; + if (remote is not null) + { + remote.CellId = result.CellId; + remote.LastServerPosition = result.Position; + remote.LastServerPositionTime = _physics.UtcNowSeconds; + remote.LastShadowSyncPosition = result.Position; + remote.LastShadowSyncOrientation = result.Orientation; + } + + _physics.Engine.ShadowObjects.CommitSetPosition( + operation.Key.LocalEntityId, + result.Position, + result.Orientation, + result.CellId, + operation.Command.ShadowWorldOffsetX, + operation.Command.ShadowWorldOffsetY, + result.ShadowAction, + result.CrossCellIds); + _physics.AcknowledgeSpatialProjection(record, spatial: true); + operation.ExactCellId = result.CellId; + operation.Result = result; + operation.WakeableLostCell = false; + operation.EnteringWorldFromCelllessResidence = false; + CancelLostFamilyDeadlines(operation); + + uint committedCellId = result.CellId; + bool IsCanonicalCommitCurrent() => + _entities.IsCurrent(record) + && ReferenceEquals(record.PhysicsBody, body) + && record.PlacementCommitVersion == canonicalCommitVersion + && record.FullCellId == committedCellId + && _physics.IsSpatialRoot(record); + Action? hitGround = remote is null ? null : remote.HitGround; + Action? leaveGround = remote is null ? null : remote.LeaveGround; + if (!PhysicsObjUpdate.CommitSetPositionTransition( + body, + result.InContact, + result.OnWalkable, + result.CollisionNormalValid, + result.CollisionNormal, + operation.PreviousContact, + operation.PreviousOnWalkable, + hitGround, + leaveGround, + IsCanonicalCommitCurrent, + () => IsCanonicalCommitCurrent() + && IsVelocityCurrent(operation))) + { + return false; + } + if (remote is not null) + remote.Airborne = !body.OnWalkable; + return IsCurrent(operation) + && IsCanonicalCommitCurrent(); + } + + private void WithdrawCanonical(RuntimeEntityRecord record) + { + _physics.RemoveSpatialProjection(record); + if (record.Key is { } key) + _physics.Engine.ShadowObjects.Suspend(key.LocalEntityId); + if (record.FullCellId != 0u) + _entities.SetFullCell(record, 0u, 0u); + } + + private void LeaveWorldCanonical(RuntimeEntityRecord record) + { + WithdrawCanonical(record); + if (record.PhysicsBody is not { } body) + { + _entities.SuspendObjectClock(record); + _entities.AdvancePlacementCommit(record); + return; + } + body.SnapToCell( + 0u, + body.Position, + body.CellPosition.Frame.Origin); + body.InWorld = false; + body.TransientState = TransientStateFlags.None; + body.ContactPlaneValid = false; + body.ContactPlaneCellId = 0u; + body.ContactPlaneIsWater = false; + body.SlidingNormal = Vector3.Zero; + body.FramesStationaryFall = 0; + body.calc_acceleration(); + if (record.RemoteMotion is IRuntimeRemotePlacement remote) + remote.CellId = 0u; + _entities.SuspendObjectClock(record); + _entities.AdvancePlacementCommit(record); + } + + private RuntimePlacementProjectionToken PublishProjection( + Operation operation, + RuntimePlacementProjectionKind kind, + in PhysicsSetPositionResult result, + bool publishImmediately = true) + { + if (operation.ProjectionSequence != 0UL) + _pendingProjection.Remove(operation.ProjectionSequence); + ulong sequence = checked(++_nextProjectionSequence); + var token = new RuntimePlacementProjectionToken( + sequence, + Revision: 1UL, + operation.Key, + operation.PositionAuthorityVersion, + operation.SpatialAuthorityVersion, + operation.PlacementCommitVersion, + operation.SessionLifetimeVersion, + operation.ExactCellId, + operation.CollisionGeneration, + operation.Command.Portal); + var snapshot = new RuntimePlacementProjectionSnapshot( + token, + kind, + result.Position, + result.Orientation, + result.CellLocalPosition, + result.InContact, + result.OnWalkable); + operation.ProjectionSequence = sequence; + _pendingProjection.Add(sequence, snapshot); + if (publishImmediately) + PublishPlacement(snapshot); + return token; + } + + private Operation CreateWithdrawalOperation( + RuntimeEntityRecord record, + RuntimeEntityKey key) + { + PhysicsBody body = record.PhysicsBody + ?? throw new InvalidOperationException( + "A withdrawal projection requires the canonical PhysicsBody."); + var physics = new PhysicsSetPositionRequest( + body.Position, + body.Orientation, + body.CellPosition.ObjCellId, + body.CellPosition.Frame.Origin, + ImmutableArray.Empty, + 1f, + 0f, + 0f); + var result = new PhysicsSetPositionResult( + PhysicsSetPositionError.Ok, + PhysicsResidenceDisposition.DeferredCell, + body.Position, + body.Orientation, + body.CellPosition.ObjCellId, + body.CellPosition.Frame.Origin, + CrossCellIds: ImmutableArray.Empty, + CollidedObjectIds: ImmutableArray.Empty); + return new Operation + { + Record = record, + Body = body, + Token = new RuntimeEntityPlacementToken( + _entities.SessionLifetimeVersion, + key, + record.PositionAuthorityVersion, + checked(++_nextOperationId)), + Key = key, + PositionAuthorityVersion = record.PositionAuthorityVersion, + SessionLifetimeVersion = _entities.SessionLifetimeVersion, + SourceSpatialAuthorityVersion = record.SpatialAuthorityVersion, + SourceVelocityAuthorityVersion = record.VelocityAuthorityVersion, + PreviousContact = body.InContact, + PreviousOnWalkable = body.OnWalkable, + Command = new RuntimeSetPositionCommand( + physics, + RuntimeSetPositionOperationKind.RemoteAuthoritative, + body.LastUpdateTime, + record.VelocityAuthorityVersion, + ShadowWorldOffsetX: 0f, + ShadowWorldOffsetY: 0f), + Result = result, + SpatialAuthorityVersion = record.SpatialAuthorityVersion, + PlacementCommitVersion = record.PlacementCommitVersion, + ExactCellId = result.CellId, + WakeableLostCell = false, + Stage = RuntimeEntityPlacementStage + .AwaitingWithdrawalAcknowledgement, + Kind = RuntimeSetPositionOperationKind.RemoteAuthoritative, + Portal = default, + }; + } + + private bool IsCurrent(Operation operation) => + _operations.TryGetValue(operation.Key, out Operation? current) + && ReferenceEquals(current, operation) + && _entities.SessionLifetimeVersion == operation.SessionLifetimeVersion + && _entities.IsCurrent(operation.Record) + && operation.Record.Key == operation.Key + && (operation.Body is null + || ReferenceEquals(operation.Record.PhysicsBody, operation.Body)) + && operation.Record.PositionAuthorityVersion + == operation.PositionAuthorityVersion + && operation.Record.SpatialAuthorityVersion + == operation.SpatialAuthorityVersion + && operation.Record.PlacementCommitVersion + == operation.PlacementCommitVersion; + + private bool IsVelocityCurrent(Operation operation) => + operation.SourceVelocityAuthorityVersion == 0UL + || operation.Record.VelocityAuthorityVersion + == operation.SourceVelocityAuthorityVersion; + + internal void PublishCancellation( + in RuntimePlacementCancellationReceipt receipt) + { + if (!receipt.IsValid) + return; + RuntimePlacementProjectionSnapshot projection = receipt.Projection; + if (_pendingProjection.TryGetValue( + projection.Token.Sequence, + out RuntimePlacementProjectionSnapshot current) + && current == projection) + { + PublishPlacement(projection); + } + } + + private RuntimePlacementCancellationReceipt CancelCore( + RuntimeEntityKey key) + { + _ = CancelCoreDeferred( + key, + cancelLostFamily: false, + preserveLostFamily: false, + out RuntimePlacementProjectionSnapshot? discard); + return discard is { } projection + ? new RuntimePlacementCancellationReceipt(projection) + : default; + } + + private bool CancelCoreDeferred( + RuntimeEntityKey key, + bool cancelLostFamily, + bool preserveLostFamily, + out RuntimePlacementProjectionSnapshot? discard) + { + discard = null; + if (!preserveLostFamily) + CancelExactLostKey(key); + if (!_operations.Remove(key, out Operation? operation)) + return false; + UnindexDeferred(operation); + if (!preserveLostFamily && cancelLostFamily) + CancelLostFamilyDeadlines(operation); + if (operation.ProjectionSequence != 0UL + && _pendingProjection.TryGetValue( + operation.ProjectionSequence, + out RuntimePlacementProjectionSnapshot pending)) + { + RuntimePlacementProjectionSnapshot cancelled = pending with + { + Token = pending.Token with + { + Revision = checked(pending.Token.Revision + 1UL), + }, + Kind = RuntimePlacementProjectionKind.Discard, + }; + _pendingProjection[operation.ProjectionSequence] = cancelled; + operation.Stage = RuntimeEntityPlacementStage + .CancelledAwaitingAcknowledgement; + discard = cancelled; + } + return true; + } + + private RuntimePlacementCancellationReceipt CancelCore( + Operation expected, + bool preserveLostFamily = false) + { + if (!_operations.TryGetValue(expected.Key, out Operation? current) + || !ReferenceEquals(current, expected)) + { + return default; + } + _ = CancelCoreDeferred( + expected.Key, + cancelLostFamily: false, + preserveLostFamily, + out RuntimePlacementProjectionSnapshot? discard); + return discard is { } projection + ? new RuntimePlacementCancellationReceipt(projection) + : default; + } + + private void IndexDeferred(Operation operation) + { + if (operation.ExactCellId == 0u + || operation.CollisionGeneration == 0UL) + { + return; + } + var bucket = new CellGenerationKey( + operation.ExactCellId, + operation.CollisionGeneration); + if (!_deferredByCellGeneration.TryGetValue( + bucket, + out List? entities)) + { + entities = []; + _deferredByCellGeneration.Add(bucket, entities); + _deferredBucketOrder.Add(bucket); + } + if (!entities.Contains(operation.Key)) + entities.Add(operation.Key); + } + + private void UnindexDeferred(Operation operation) + { + if (operation.ExactCellId == 0u) + { + return; + } + if (operation.CollisionGeneration == 0UL) + { + if (_unboundDeferredByCell.TryGetValue( + operation.ExactCellId, + out List? unbound)) + { + int unboundIndex = unbound.IndexOf(operation.Key); + if (unboundIndex >= 0) + { + int last = unbound.Count - 1; + unbound[unboundIndex] = unbound[last]; + unbound.RemoveAt(last); + } + if (unbound.Count == 0) + { + _unboundDeferredByCell.Remove(operation.ExactCellId); + _unboundDeferredCellOrder.Remove(operation.ExactCellId); + } + } + return; + } + var bucket = new CellGenerationKey( + operation.ExactCellId, + operation.CollisionGeneration); + if (_deferredByCellGeneration.TryGetValue( + bucket, + out List? entities)) + { + int index = entities.IndexOf(operation.Key); + if (index >= 0) + { + int last = entities.Count - 1; + entities[index] = entities[last]; + entities.RemoveAt(last); + } + if (entities.Count == 0) + RemoveDeferredBucket(bucket); + } + } + + private void UnbindDeferredBucket(CellGenerationKey bucket) + { + if (!_deferredByCellGeneration.TryGetValue( + bucket, + out List? entities)) + { + return; + } + RemoveDeferredBucket(bucket); + if (!_unboundDeferredByCell.TryGetValue( + bucket.CellId, + out List? unbound)) + { + unbound = []; + _unboundDeferredByCell.Add(bucket.CellId, unbound); + _unboundDeferredCellOrder.Add(bucket.CellId); + } + for (int index = 0; index < entities.Count; index++) + { + RuntimeEntityKey key = entities[index]; + if (!_operations.TryGetValue(key, out Operation? operation) + || !operation.WakeableLostCell + || operation.ExactCellId != bucket.CellId + || operation.CollisionGeneration != bucket.CollisionGeneration) + { + continue; + } + operation.CollisionGeneration = 0UL; + operation.CollisionGenerationReady = false; + if (!unbound.Contains(key)) + unbound.Add(key); + } + if (unbound.Count == 0) + { + _unboundDeferredByCell.Remove(bucket.CellId); + _unboundDeferredCellOrder.Remove(bucket.CellId); + } + } + + private void RemoveDeferredBucket(CellGenerationKey bucket) + { + _deferredByCellGeneration.Remove(bucket); + int index = _deferredBucketOrder.IndexOf(bucket); + if (index < 0) + return; + int last = _deferredBucketOrder.Count - 1; + _deferredBucketOrder[index] = _deferredBucketOrder[last]; + _deferredBucketOrder.RemoveAt(last); + } + + private static RuntimeSetPositionOutcome Outcome( + RuntimeSetPositionStatus status, + in PhysicsSetPositionResult result, + in RuntimePlacementProjectionToken projection) => new( + status, + result.Error, + result.Residence, + result.CellId, + projection); + + private static RuntimeSetPositionOutcome Rejected( + in PhysicsSetPositionRequest request) => new( + RuntimeSetPositionStatus.Rejected, + PhysicsSetPositionError.InvalidArguments, + PhysicsResidenceDisposition.Unchanged, + request.CellId, + default); + + private static PhysicsSetPositionResult InvalidResult( + in PhysicsSetPositionRequest request) => new( + PhysicsSetPositionError.InvalidArguments, + PhysicsResidenceDisposition.Unchanged, + request.Position, + request.Orientation, + request.CellId, + request.CellLocalPosition, + CrossCellIds: ImmutableArray.Empty, + CollidedObjectIds: ImmutableArray.Empty); + + private static bool IsStructurallyValid( + in PhysicsSetPositionRequest request) + { + static bool Finite(Vector3 value) => float.IsFinite(value.X) + && float.IsFinite(value.Y) + && float.IsFinite(value.Z); + + // The wire/runtime frame is retail Position::IsValid composed with + // Frame::IsValid: valid inbound cell plus the exact unit-quaternion + // tolerance. This must run before Core or prepared-mover caching. + if (!PositionFrameValidation.IsValid( + request.CellId, + request.CellLocalPosition, + request.Orientation) + || !Finite(request.Position) + || !Finite(request.CellLocalPosition) + || !float.IsFinite(request.StepUpHeight) + || !float.IsFinite(request.StepDownHeight)) + { + return false; + } + if (!request.Spheres.IsDefaultOrEmpty) + { + if (!float.IsFinite(request.Scale)) + return false; + int count = Math.Min(request.Spheres.Length, 2); + for (int index = 0; index < count; index++) + { + FlatCollisionSphere sphere = request.Spheres[index]; + if (!Finite(sphere.Origin) || !float.IsFinite(sphere.Radius)) + return false; + } + } + if (request.Flags.HasFlag(PhysicsSetPositionFlags.Line) + && !Finite(request.Line)) + { + return false; + } + bool scatter = request.Flags.HasFlag(PhysicsSetPositionFlags.Scatter) + || request.Flags.HasFlag(PhysicsSetPositionFlags.RandomScatter); + if (scatter + && (!float.IsFinite(request.ScatterRadiusX) + || !float.IsFinite(request.ScatterRadiusY) + || request.ScatterAttempts > MaxSynchronousScatterAttempts)) + { + return false; + } + return true; + } + + private void PublishPlacement( + in RuntimePlacementProjectionSnapshot projection) => + _events?.PublishPlacement(projection); + + private void ArmLostFamilyDeadlines(Operation operation) + { + RuntimeEntityRecord root = operation.Record; + CancelLostFamilyDeadlines(operation); + double deadline = _physics.MonotonicNowSeconds + 25d; + operation.LostFamilyKeys ??= []; + if (root.Key is { } rootKey) + { + operation.LostFamilyKeys.Add(rootKey); + ArmLostDeadline(rootKey, deadline); + } + IReadOnlyList children = _entities.ParentAttachments + .ChildrenAttachedToParent(root.ServerGuid, root.Incarnation); + for (int index = 0; index < children.Count; index++) + { + if (_entities.TryGetActive( + children[index], + out RuntimeEntityRecord child) + && child.Key is { } childKey) + { + operation.LostFamilyKeys.Add(childKey); + ArmLostDeadline(childKey, deadline); + } + } + } + + private void CancelLostFamilyDeadlines(Operation operation) + { + if (operation.Record.Key is { } rootKey) + { + RemoveLostDeadline(rootKey); + RemoveExpiredLostCell(rootKey); + } + IReadOnlyList currentChildren = _entities.ParentAttachments + .ChildrenAttachedToParent( + operation.Record.ServerGuid, + operation.Record.Incarnation); + for (int index = 0; index < currentChildren.Count; index++) + { + if (_entities.TryGetActive( + currentChildren[index], + out RuntimeEntityRecord child) + && child.Key is { } key) + { + RemoveLostDeadline(key); + RemoveExpiredLostCell(key); + } + } + operation.LostFamilyKeys?.Clear(); + } + + private void CancelExactLostKey(RuntimeEntityKey key) + { + RemoveLostDeadline(key); + RemoveExpiredLostCell(key); + foreach (Operation operation in _operations.Values) + operation.LostFamilyKeys?.Remove(key); + } + + private void RemoveExpiredLostCell(RuntimeEntityKey key) + { + if (!_expiredLostCellNodes.Remove( + key, + out LinkedListNode? node)) + { + return; + } + _expiredLostCells.Remove(node); + } + + private void ArmLostDeadline(RuntimeEntityKey key, double deadline) + { + RemoveLostDeadlineNode(key); + _lostDeadlines[key] = deadline; + int index = _lostDeadlineNodes.Count; + _lostDeadlineNodes.Add(new LostDeadlineEntry( + key, + deadline, + checked(++_nextLostDeadlineSequence))); + _lostDeadlineNodeIndex.Add(key, index); + BubbleLostDeadlineUp(index); + } + + private void RemoveLostDeadline(RuntimeEntityKey key) + { + _lostDeadlines.Remove(key); + RemoveLostDeadlineNode(key); + } + + private static bool IsEarlier( + in LostDeadlineEntry candidate, + in LostDeadlineEntry current) => + candidate.Deadline < current.Deadline + || (candidate.Deadline == current.Deadline + && candidate.Sequence < current.Sequence); + + private void BubbleLostDeadlineUp(int index) + { + while (index > 0) + { + int parent = (index - 1) / 2; + if (!IsEarlier( + _lostDeadlineNodes[index], + _lostDeadlineNodes[parent])) + break; + SwapLostDeadlineNodes(index, parent); + index = parent; + } + } + + private void BubbleLostDeadlineDown(int index) + { + while (true) + { + int left = checked(index * 2 + 1); + if (left >= _lostDeadlineNodes.Count) + return; + int right = left + 1; + int earlier = right < _lostDeadlineNodes.Count + && IsEarlier( + _lostDeadlineNodes[right], + _lostDeadlineNodes[left]) + ? right + : left; + if (!IsEarlier( + _lostDeadlineNodes[earlier], + _lostDeadlineNodes[index])) + return; + SwapLostDeadlineNodes(index, earlier); + index = earlier; + } + } + + private void SwapLostDeadlineNodes(int first, int second) + { + LostDeadlineEntry temporary = _lostDeadlineNodes[first]; + _lostDeadlineNodes[first] = _lostDeadlineNodes[second]; + _lostDeadlineNodes[second] = temporary; + _lostDeadlineNodeIndex[_lostDeadlineNodes[first].Key] = first; + _lostDeadlineNodeIndex[_lostDeadlineNodes[second].Key] = second; + } + + private void RemoveLostDeadlineNode(RuntimeEntityKey key) + { + if (_lostDeadlineNodeIndex.TryGetValue(key, out int index)) + RemoveLostDeadlineNodeAt(index); + } + + private void RemoveLostDeadlineNodeAt(int index) + { + LostDeadlineEntry removed = _lostDeadlineNodes[index]; + _lostDeadlineNodeIndex.Remove(removed.Key); + int last = _lostDeadlineNodes.Count - 1; + if (index != last) + { + LostDeadlineEntry moved = _lostDeadlineNodes[last]; + _lostDeadlineNodes[index] = moved; + _lostDeadlineNodeIndex[moved.Key] = index; + } + _lostDeadlineNodes.RemoveAt(last); + if (index >= _lostDeadlineNodes.Count) + return; + int parent = index == 0 ? -1 : (index - 1) / 2; + if (parent >= 0 + && IsEarlier( + _lostDeadlineNodes[index], + _lostDeadlineNodes[parent])) + { + BubbleLostDeadlineUp(index); + } + else + { + BubbleLostDeadlineDown(index); + } + } + + private void EnsureNotDisposed() => + ObjectDisposedException.ThrowIf(_disposed, this); +} diff --git a/tests/AcDream.Core.Tests/Physics/ShadowSetPositionCommitTests.cs b/tests/AcDream.Core.Tests/Physics/ShadowSetPositionCommitTests.cs new file mode 100644 index 00000000..4d914cd1 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/ShadowSetPositionCommitTests.cs @@ -0,0 +1,167 @@ +using System.Collections.Immutable; +using System.Numerics; +using AcDream.Core.Physics; + +namespace AcDream.Core.Tests.Physics; + +public sealed class ShadowSetPositionCommitTests +{ + private const uint Landblock = 0xA9B40000u; + private const uint Cell1 = Landblock | 0x0001u; + private const uint Cell9 = Landblock | 0x0009u; + + [Fact] + public void NoneRefreshesFrameWithoutChangingExactMembership() + { + var registry = RegisteredSingle(); + ulong version = registry.GetOwnerVersion(1u); + var moved = new Vector3(14f, 12f, 50f); + + registry.CommitSetPosition( + 1u, moved, Quaternion.Identity, Cell1, 0f, 0f, + PhysicsShadowCommitAction.None, []); + + ShadowEntry entry = Assert.Single(registry.GetObjectsInCell(Cell1)); + Assert.Equal(moved, entry.Position); + Assert.Empty(registry.GetObjectsInCell(Cell9)); + Assert.Equal(version + 1UL, registry.GetOwnerVersion(1u)); + } + + [Fact] + public void ReplaceDeduplicatesExactCellsAndPreserveRetainsThem() + { + var registry = RegisteredSingle(); + var replaced = new Vector3(36f, 12f, 50f); + registry.CommitSetPosition( + 1u, replaced, Quaternion.Identity, Cell9, 0f, 0f, + PhysicsShadowCommitAction.Replace, + ImmutableArray.Create(Cell9, Cell9, 0u)); + + Assert.Empty(registry.GetObjectsInCell(Cell1)); + Assert.Equal(replaced, + Assert.Single(registry.GetObjectsInCell(Cell9)).Position); + + var preserved = new Vector3(38f, 12f, 50f); + registry.CommitSetPosition( + 1u, preserved, Quaternion.Identity, Cell9, 0f, 0f, + PhysicsShadowCommitAction.Preserve, []); + Assert.Equal(preserved, + Assert.Single(registry.GetObjectsInCell(Cell9)).Position); + + var emptyReplace = new Vector3(40f, 12f, 50f); + registry.CommitSetPosition( + 1u, emptyReplace, Quaternion.Identity, Cell9, 0f, 0f, + PhysicsShadowCommitAction.Replace, []); + Assert.Equal(emptyReplace, + Assert.Single(registry.GetObjectsInCell(Cell9)).Position); + } + + [Fact] + public void RecalculateUsesCanonicalFloodInsteadOfRetainedCells() + { + var registry = RegisteredSingle(); + var moved = new Vector3(36f, 12f, 50f); + + registry.CommitSetPosition( + 1u, moved, Quaternion.Identity, Cell9, 0f, 0f, + PhysicsShadowCommitAction.Recalculate, []); + + Assert.Empty(registry.GetObjectsInCell(Cell1)); + Assert.Equal(moved, + Assert.Single(registry.GetObjectsInCell(Cell9)).Position); + } + + [Fact] + public void SuspendedPreserveRestoresExactRowsAndConsumesReceipt() + { + var registry = RegisteredSingle(); + Assert.True(registry.Suspend(1u)); + Assert.Equal(0, registry.TotalRegistered); + Assert.Equal(1, registry.SuspendedRegistrationCount); + var moved = new Vector3(15f, 12f, 50f); + + registry.CommitSetPosition( + 1u, moved, Quaternion.Identity, Cell1, 0f, 0f, + PhysicsShadowCommitAction.Preserve, []); + + Assert.Equal(1, registry.TotalRegistered); + Assert.Equal(0, registry.SuspendedRegistrationCount); + Assert.Equal(moved, + Assert.Single(registry.GetObjectsInCell(Cell1)).Position); + } + + [Fact] + public void SuspendedReceiptCopiesAcrossCollisionRootAndClearsOnTeardown() + { + var source = RegisteredSingle(); + Assert.True(source.Suspend(1u)); + var destination = new ShadowObjectRegistry(); + + Assert.False(destination.RefreshRetainedOwnerFrom( + source, 1u, Landblock, out ulong version)); + Assert.Equal(source.GetOwnerVersion(1u), version); + Assert.Equal(1, destination.RetainedRegistrationCount); + Assert.Equal(1, destination.SuspendedRegistrationCount); + + destination.CommitSetPosition( + 1u, new Vector3(16f, 12f, 50f), Quaternion.Identity, + Cell1, 0f, 0f, PhysicsShadowCommitAction.None, []); + Assert.Single(destination.GetObjectsInCell(Cell1)); + destination.Deregister(1u); + Assert.Equal(0, destination.RetainedRegistrationCount); + Assert.Equal(0, destination.SuspendedRegistrationCount); + destination.Clear(); + Assert.Equal(0, destination.TotalRegistered); + } + + [Fact] + public void MultiPartNoneRefreshesEachPartFromRootTransform() + { + var registry = new ShadowObjectRegistry(); + IReadOnlyList shapes = + [ + Shape(0x01000001u, new Vector3(1f, 0f, 0f)), + Shape(0x01000002u, new Vector3(0f, 1f, 0f)), + ]; + registry.RegisterMultiPart( + 2u, new Vector3(12f, 12f, 50f), Quaternion.Identity, + shapes, 0u, EntityCollisionFlags.None, 0f, 0f, Landblock, + Cell1); + Quaternion rotation = Quaternion.CreateFromAxisAngle( + Vector3.UnitZ, MathF.PI / 2f); + var root = new Vector3(14f, 12f, 50f); + + registry.CommitSetPosition( + 2u, root, rotation, Cell1, 0f, 0f, + PhysicsShadowCommitAction.None, []); + + ShadowEntry[] entries = registry.GetObjectsInCell(Cell1) + .Where(entry => entry.EntityId == 2u) + .OrderBy(entry => entry.GfxObjId) + .ToArray(); + Assert.Equal(2, entries.Length); + Assert.Equal(root + Vector3.Transform(shapes[0].LocalPosition, rotation), + entries[0].Position); + Assert.Equal(root + Vector3.Transform(shapes[1].LocalPosition, rotation), + entries[1].Position); + } + + private static ShadowObjectRegistry RegisteredSingle() + { + var registry = new ShadowObjectRegistry(); + registry.Register( + 1u, 0x01000001u, new Vector3(12f, 12f, 50f), + Quaternion.Identity, 1f, 0f, 0f, Landblock, + seedCellId: Cell1, isStatic: false); + return registry; + } + + private static ShadowShape Shape(uint gfxObjId, Vector3 local) => new( + gfxObjId, + local, + Quaternion.Identity, + 1f, + ShadowCollisionType.BSP, + 0.25f, + 0f); +} diff --git a/tests/AcDream.Runtime.Tests/Entities/ParentAttachmentStateTests.cs b/tests/AcDream.Runtime.Tests/Entities/ParentAttachmentStateTests.cs index dcdd433c..7a1f3811 100644 --- a/tests/AcDream.Runtime.Tests/Entities/ParentAttachmentStateTests.cs +++ b/tests/AcDream.Runtime.Tests/Entities/ParentAttachmentStateTests.cs @@ -264,6 +264,87 @@ public sealed class ParentAttachmentStateTests Assert.Equal((ushort)6, projection.ChildPositionSequence); } + [Fact] + public void CommittedChildrenAppendInCommitOrder() + { + const uint parentGuid = 0x70000200u; + var relations = new ParentAttachmentState(); + + Commit(relations, Relation(parentGuid, 0x70000201u, parentInstance: 9)); + Commit(relations, Relation(parentGuid, 0x70000202u, parentInstance: 9)); + IReadOnlyList committed = relations.ChildrenAttachedToParent( + parentGuid, + 9); + Commit(relations, Relation(parentGuid, 0x70000203u, parentInstance: 9)); + + Assert.Same( + committed, + relations.ChildrenAttachedToParent(parentGuid, 9)); + Assert.Equal( + [0x70000201u, 0x70000202u, 0x70000203u], + committed); + } + + [Fact] + public void EndingChildProjectionSwapRemovesCommittedChild() + { + const uint parentGuid = 0x70000210u; + var relations = new ParentAttachmentState(); + Commit(relations, Relation(parentGuid, 0x70000211u, parentInstance: 9)); + Commit(relations, Relation(parentGuid, 0x70000212u, parentInstance: 9)); + Commit(relations, Relation(parentGuid, 0x70000213u, parentInstance: 9)); + + relations.EndChildProjection(0x70000211u); + + Assert.Equal( + [0x70000213u, 0x70000212u], + relations.ChildrenAttachedToParent(parentGuid, 9)); + } + + [Fact] + public void ReparentSwapRemovesOldEntryAndAppendsNewEntry() + { + const uint oldParentGuid = 0x70000220u; + const uint newParentGuid = 0x70000221u; + const uint childGuid = 0x70000222u; + var relations = new ParentAttachmentState(); + Commit(relations, Relation(oldParentGuid, childGuid, parentInstance: 9)); + Commit(relations, Relation(oldParentGuid, 0x70000223u, parentInstance: 9)); + Commit(relations, Relation(oldParentGuid, 0x70000224u, parentInstance: 9)); + Commit(relations, Relation(newParentGuid, 0x70000225u, parentInstance: 4)); + + Commit(relations, Relation(newParentGuid, childGuid, parentInstance: 4)); + + Assert.Equal( + [0x70000224u, 0x70000223u], + relations.ChildrenAttachedToParent(oldParentGuid, 9)); + Assert.Equal( + [0x70000225u, childGuid], + relations.ChildrenAttachedToParent(newParentGuid, 4)); + } + + [Fact] + public void CommittedChildrenAreIsolatedByParentGuidAndIncarnation() + { + const uint parentGuid = 0x70000230u; + const uint otherParentGuid = 0x70000231u; + var relations = new ParentAttachmentState(); + Commit(relations, Relation(parentGuid, 0x70000232u, parentInstance: 9)); + Commit(relations, Relation(parentGuid, 0x70000233u, parentInstance: 10)); + Commit(relations, Relation(otherParentGuid, 0x70000234u, parentInstance: 9)); + + Assert.Equal( + [0x70000232u], + relations.ChildrenAttachedToParent(parentGuid, 9)); + Assert.Equal( + [0x70000233u], + relations.ChildrenAttachedToParent(parentGuid, 10)); + Assert.Equal( + [0x70000234u], + relations.ChildrenAttachedToParent(otherParentGuid, 9)); + Assert.Empty(relations.ChildrenAttachedToParent(otherParentGuid, 10)); + } + private static void Resolve( ParentAttachmentState relations, InboundPhysicsStateController inbound, @@ -290,6 +371,20 @@ public sealed class ParentAttachmentStateTests out accepted) && relations.CommitProjection(relation); + private static void Commit( + ParentAttachmentState relations, + ParentAttachmentRelation relation) + { + relations.AcceptCreateObjectRelation(relation); + Assert.True(relations.CommitProjection(relation)); + } + + private static ParentAttachmentRelation Relation( + uint parentGuid, + uint childGuid, + ushort parentInstance) => + new(parentGuid, childGuid, 1, 2, parentInstance, 1); + private static WorldSession.EntitySpawn Spawn( uint guid, ushort instance, diff --git a/tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionStateTests.cs b/tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionStateTests.cs new file mode 100644 index 00000000..1762d8f9 --- /dev/null +++ b/tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionStateTests.cs @@ -0,0 +1,2449 @@ +using System.Collections.Immutable; +using System.Numerics; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.Core.Physics; +using AcDream.Core.Physics.Motion; +using AcDream.Core.World.Cells; +using AcDream.Runtime.Entities; +using AcDream.Runtime.Physics; +using DatReaderWriter.Enums; +using DatReaderWriter.Types; + +namespace AcDream.Runtime.Tests.Physics; + +public sealed class RuntimeSetPositionStateTests +{ + private const uint SourceLandblock = 0xA9B40000u; + private const uint SourceCell = SourceLandblock | 0x0001u; + private const uint DestinationLandblock = 0xAAB40000u; + private const uint DestinationCell = DestinationLandblock | 0x0001u; + private const uint DestinationIndoorCell = DestinationLandblock | 0x0100u; + + [Fact] + public void AcceptedTokenExistsBeforeHostPreparationAndRejectsInvalidPortalAuthority() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimeEntityRecord record = CreateRecord(lifetime, 0x70001001u, 1); + + RuntimeEntityPlacementToken token = lifetime.Physics.SetPosition + .BeginAcceptedPlacement( + record, + record.PositionAuthorityVersion, + RuntimeSetPositionOperationKind.RemoteAuthoritative); + + Assert.True(token.IsValid); + Assert.Equal(record.Key, token.Entity); + Assert.Equal(1, lifetime.Physics.CaptureOwnership() + .AwaitingSetPositionPreparationCount); + + var invalidPortal = new RuntimePortalPlacementAuthority( + Present: true, + RevealGeneration: 7, + TeleportSequence: 0, + new RuntimeWorldHostProjectionToken(7, DestinationCell)); + Assert.False(lifetime.Physics.SetPosition.BeginAcceptedPlacement( + record, + record.PositionAuthorityVersion, + RuntimeSetPositionOperationKind.RemoteAuthoritative, + invalidPortal).IsValid); + + // 4B1 validates the immutable authority shape. The authoritative + // RuntimeWorldTransitState membership check is intentionally the + // tested 4B2 routing seam. + RuntimeEntityPlacementToken portalToken = lifetime.Physics.SetPosition + .BeginAcceptedPlacement( + record, + record.PositionAuthorityVersion, + RuntimeSetPositionOperationKind.LocalAuthoritative, + invalidPortal); + Assert.True(portalToken.IsValid); + } + + [Fact] + public void ImmediateCommitIsCanonicalBeforeProjectionAndExactTokenRetriesUntilAck() + { + PhysicsEngine engine = FlatEngine(SourceLandblock, 0f); + using var lifetime = new RuntimeEntityObjectLifetime(engine); + RuntimeEntityRecord record = CreateRecord(lifetime, 0x70001002u, 1); + PhysicsBody body = AttachBody(lifetime, record, SourceCell); + var observer = new PlacementObserver(delta => + { + Assert.Equal(RuntimePlacementProjectionKind.Place, + delta.Placement.Kind); + Assert.Same(body, record.PhysicsBody); + Assert.Equal(SourceCell, record.FullCellId); + Assert.Equal(new Vector3(12f, 18f, 7f), body.Position); + Assert.True(body.InWorld); + Assert.True(lifetime.Physics.IsSpatialRoot(record)); + }); + using IDisposable subscription = lifetime.Events.SubscribePlacement(observer); + + RuntimeSetPositionOutcome outcome = lifetime.Physics.SetPosition.Apply( + record, + record.PositionAuthorityVersion, + Command(Request(SourceCell, new Vector3(12f, 18f, 7f)))); + + Assert.Equal( + RuntimeSetPositionStatus.CommittedHostAcknowledgementPending, + outcome.Status); + Assert.Single(observer.Deltas); + RuntimePlacementProjectionToken token = outcome.Projection; + lifetime.Physics.SetPosition.RetryPendingProjections(); + Assert.Equal(2, observer.Deltas.Count); + Assert.Equal(token, observer.Deltas[0].Placement.Token); + Assert.Equal(token, observer.Deltas[1].Placement.Token); + Assert.True(observer.Deltas[1].Stamp.Sequence + > observer.Deltas[0].Stamp.Sequence); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection(token)); + RuntimeSetPositionOwnershipSnapshot ownership = + lifetime.Physics.SetPosition.CaptureOwnership(); + Assert.Equal(0, ownership.ActiveOperationCount); + Assert.Equal(0, ownership.PendingProjectionAcknowledgementCount); + Assert.Equal(1, ownership.PreparedMoverCount); + } + + [Fact] + public void InWorldSetPositionPreservesObjectClockEpochPendingTimeAndActiveState() + { + PhysicsEngine engine = FlatEngine(SourceLandblock, 0f); + using var lifetime = new RuntimeEntityObjectLifetime(engine); + RuntimeEntityRecord record = CreateRecord(lifetime, 0x70001110u, 1); + PhysicsBody body = AttachBody(lifetime, record, SourceCell); + body.LastUpdateTime = 3d; + _ = record.ObjectClock.Advance(0.01d); + ulong epoch = record.ObjectClockEpoch; + + RuntimeSetPositionOutcome outcome = lifetime.Physics.SetPosition.Apply( + record, + record.PositionAuthorityVersion, + Command(Request(SourceCell, new Vector3(11f, 18f, 7f)))); + + Assert.Equal( + RuntimeSetPositionStatus.CommittedHostAcknowledgementPending, + outcome.Status); + Assert.Equal(epoch, record.ObjectClockEpoch); + Assert.Equal(3d, body.LastUpdateTime); + Assert.Equal(0.01d, record.ObjectClock.PendingSeconds, precision: 12); + Assert.True(record.ObjectClock.IsActive); + Assert.True(body.TransientState.HasFlag(TransientStateFlags.Active)); + } + + [Fact] + public void InWorldSetPositionDoesNotWakeAnInactiveBody() + { + PhysicsEngine engine = FlatEngine(SourceLandblock, 0f); + using var lifetime = new RuntimeEntityObjectLifetime(engine); + RuntimeEntityRecord record = CreateRecord(lifetime, 0x70001111u, 1); + PhysicsBody body = AttachBody(lifetime, record, SourceCell); + body.LastUpdateTime = 4d; + record.ObjectClock.Deactivate(); + body.TransientState &= ~TransientStateFlags.Active; + ulong epoch = record.ObjectClockEpoch; + + RuntimeSetPositionOutcome outcome = lifetime.Physics.SetPosition.Apply( + record, + record.PositionAuthorityVersion, + Command(Request(SourceCell, new Vector3(11f, 19f, 7f)))); + + Assert.Equal( + RuntimeSetPositionStatus.CommittedHostAcknowledgementPending, + outcome.Status); + Assert.Equal(epoch, record.ObjectClockEpoch); + Assert.Equal(4d, body.LastUpdateTime); + Assert.False(record.ObjectClock.IsActive); + Assert.False(body.TransientState.HasFlag(TransientStateFlags.Active)); + } + + [Fact] + public void WarmedImmediateCommitAllocationIsMeasuredBeforeProductionCutover() + { + PhysicsEngine engine = FlatEngine(SourceLandblock, 0f); + using var lifetime = new RuntimeEntityObjectLifetime(engine); + RuntimeEntityRecord record = CreateRecord(lifetime, 0x7000111Cu, 1); + _ = AttachBody(lifetime, record, SourceCell); + RuntimeSetPositionCommand command = Command(Request( + SourceCell, + new Vector3(11f, 18f, 7f))); + for (int warm = 0; warm < 64; warm++) + { + RuntimeSetPositionOutcome outcome = lifetime.Physics.SetPosition.Apply( + record, + record.PositionAuthorityVersion, + command); + if (!lifetime.Physics.SetPosition.AcknowledgeProjection( + outcome.Projection)) + { + throw new InvalidOperationException("Warmup acknowledgement failed."); + } + } + + const int iterations = 1_000; + long before = GC.GetAllocatedBytesForCurrentThread(); + for (int iteration = 0; iteration < iterations; iteration++) + { + RuntimeSetPositionOutcome outcome = lifetime.Physics.SetPosition.Apply( + record, + record.PositionAuthorityVersion, + command); + if (!lifetime.Physics.SetPosition.AcknowledgeProjection( + outcome.Projection)) + { + throw new InvalidOperationException("Measured acknowledgement failed."); + } + } + long allocated = GC.GetAllocatedBytesForCurrentThread() - before; + + // The dormant 4B1 owner still allocates its operation/projection + // envelope. Pin the measured Release ceiling so 4B2 cannot activate + // the route without making this cost explicit or reducing it. + Assert.InRange(allocated / iterations, 1L, 2_048L); + } + + [Fact] + public void MissingCrossLandblockCellParksExactBodyThenMatchingCellGenerationWakesIt() + { + PhysicsEngine engine = FlatEngine(SourceLandblock, 0f); + var gameClock = new GameRuntimeClock(); + _ = gameClock.Advance(35d); + var time = new ManualTimeProvider( + DateTimeOffset.UnixEpoch.AddSeconds(100d)); + using var lifetime = new RuntimeEntityObjectLifetime( + engine, + timeProvider: time, + gameClock: gameClock); + RuntimeEntityRecord record = CreateRecord(lifetime, 0x70001003u, 1); + PhysicsBody body = AttachBody(lifetime, record, SourceCell); + var remote = new RemoteMotion(body) + { + HasServerVelocity = true, + ServerVelocity = Vector3.UnitX, + }; + lifetime.Physics.SetRemoteMotion(record, remote); + body.TransientState = TransientStateFlags.Active + | TransientStateFlags.Contact + | TransientStateFlags.OnWalkable + | TransientStateFlags.WaterContact + | TransientStateFlags.Sliding; + body.ContactPlaneValid = true; + body.ContactPlaneIsWater = true; + body.SlidingNormal = Vector3.UnitX; + body.set_velocity(new Vector3(3f, 4f, 5f)); + engine.ShadowObjects.Register( + record.Key!.Value.LocalEntityId, + 0x01000001u, + body.Position, + body.Orientation, + 0.48f, + 0f, + 0f, + SourceLandblock, + seedCellId: SourceCell, + isStatic: false); + var observer = new PlacementObserver(); + using IDisposable subscription = lifetime.Events.SubscribePlacement(observer); + + RuntimeSetPositionOutcome deferred = lifetime.Physics.SetPosition.Apply( + record, + record.PositionAuthorityVersion, + Command(CrossLandblockRequest())); + + Assert.Equal(RuntimeSetPositionStatus.DeferredCell, deferred.Status); + Assert.Equal(DestinationCell, deferred.ExactCellId); + Assert.Same(body, record.PhysicsBody); + Assert.Equal(DestinationCell, body.CellPosition.ObjCellId); + Assert.Equal(0u, record.FullCellId); + Assert.False(body.InWorld); + Assert.Equal(1d, body.LastUpdateTime); + Assert.False(body.TransientState.HasFlag(TransientStateFlags.Active)); + Assert.True(body.InContact); + Assert.True(body.OnWalkable); + Assert.True(body.ContactPlaneIsWater); + Assert.Equal(Vector3.UnitX, body.SlidingNormal); + Assert.Equal(new Vector3(3f, 4f, 5f), body.Velocity); + Assert.False(lifetime.Physics.IsSpatialRoot(record)); + Assert.Equal(1, engine.ShadowObjects.SuspendedRegistrationCount); + Assert.Equal(RuntimePlacementProjectionKind.Withdraw, + Assert.Single(observer.Deltas).Placement.Kind); + + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + deferred.Projection)); + ulong suspendedEpoch = record.ObjectClockEpoch; + Assert.False(record.ObjectClock.IsActive); + lifetime.Physics.SetPosition.CommitCollisionGeneration( + DestinationLandblock, + generation: 2, + ready: true); + Assert.Single(observer.Deltas); + + AddFlatLandblock(engine, DestinationLandblock, 192f); + _ = gameClock.Advance(5d); + lifetime.Physics.SetPosition.CommitCollisionGeneration( + DestinationLandblock, + generation: 1, + ready: true); + + Assert.Equal(2, observer.Deltas.Count); + RuntimePlacementProjectionSnapshot placed = observer.Deltas[1].Placement; + Assert.Equal(RuntimePlacementProjectionKind.Place, placed.Kind); + Assert.Equal(DestinationCell, placed.Token.ExactCellId); + Assert.Same(body, record.PhysicsBody); + Assert.Equal(DestinationCell, record.FullCellId); + Assert.True(body.InWorld); + Assert.True(double.IsFinite(body.LastUpdateTime)); + Assert.Equal(40d, body.LastUpdateTime); + Assert.Equal(suspendedEpoch + 1UL, record.ObjectClockEpoch); + Assert.Equal(0d, record.ObjectClock.PendingSeconds); + Assert.True(record.ObjectClock.IsActive); + Assert.True(body.TransientState.HasFlag(TransientStateFlags.Active)); + Assert.True(lifetime.Physics.IsSpatialRoot(record)); + Assert.Equal(100d, remote.LastServerPosTime); + var remoteUpdater = new RuntimeRemotePhysicsUpdater( + lifetime.Physics); + Assert.True(remoteUpdater.Tick( + record, + remote, + objectScale: 1f, + sequencer: null, + dt: 0.01f, + objectClockEpoch: record.ObjectClockEpoch, + new MotionDeltaFrame + { + Orientation = Quaternion.Identity, + }, + radius: 0.48f, + height: 1.835f, + liveCenterX: 1, + liveCenterY: 1)); + Assert.True(remote.HasServerVelocity); + Assert.Equal(Vector3.UnitX, remote.ServerVelocity); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + placed.Token)); + Assert.Equal(0, lifetime.Physics.SetPosition.CaptureOwnership() + .ActiveOperationCount); + } + + [Fact] + public void FailedDeferredWakeRetainsLostOwnerDeadlineAndRetries() + { + PhysicsEngine engine = FlatEngine(SourceLandblock, 0f); + using var lifetime = new RuntimeEntityObjectLifetime(engine); + RuntimeEntityRecord record = CreateRecord(lifetime, 0x70001121u, 1); + PhysicsBody body = AttachBody(lifetime, record, SourceCell); + var observer = new PlacementObserver(); + using IDisposable subscription = lifetime.Events.SubscribePlacement(observer); + RuntimeSetPositionOutcome deferred = lifetime.Physics.SetPosition.Apply( + record, + record.PositionAuthorityVersion, + Command(CrossLandblockRequest())); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + deferred.Projection)); + + AddFlatLandblock(engine, DestinationLandblock, 192f); + engine.TransitionCellCollisionTestHook = + static (_, _, _, _) => TransitionState.Collided; + lifetime.Physics.SetPosition.CommitCollisionGeneration( + DestinationLandblock, + generation: 1, + ready: true); + + RuntimeSetPositionOwnershipSnapshot failed = lifetime.Physics + .SetPosition.CaptureOwnership(); + Assert.Equal(1, failed.ActiveOperationCount); + Assert.Equal(1, failed.DeferredCellCount); + Assert.Equal(1, failed.LostDeadlineCount); + Assert.Equal(1, failed.DeferredBucketCount); + Assert.True(failed.IndexesConsistent); + Assert.Equal(0u, record.FullCellId); + Assert.False(body.InWorld); + Assert.Single(observer.Deltas); + + engine.TransitionCellCollisionTestHook = null; + lifetime.Physics.SetPosition.CommitCollisionGeneration( + DestinationLandblock, + generation: 1, + ready: true); + RuntimePlacementProjectionSnapshot placed = observer.Deltas[^1].Placement; + Assert.Equal(RuntimePlacementProjectionKind.Place, placed.Kind); + Assert.Equal(record.Key, placed.Token.Entity); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + placed.Token)); + Assert.Equal(DestinationCell, record.FullCellId); + Assert.True(body.InWorld); + } + + [Fact] + public void StandaloneDeferredWakeRetainsAcceptedSimulationTimeFallback() + { + PhysicsEngine engine = FlatEngine(SourceLandblock, 0f); + using var lifetime = new RuntimeEntityObjectLifetime(engine); + RuntimeEntityRecord record = CreateRecord(lifetime, 0x70001126u, 1); + PhysicsBody body = AttachBody(lifetime, record, SourceCell); + RuntimeSetPositionOutcome deferred = lifetime.Physics.SetPosition.Apply( + record, + record.PositionAuthorityVersion, + Command(CrossLandblockRequest())); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + deferred.Projection)); + AddFlatLandblock(engine, DestinationLandblock, 192f); + + lifetime.Physics.SetPosition.CommitCollisionGeneration( + DestinationLandblock, + generation: 1, + ready: true); + + Assert.Equal(10d, body.LastUpdateTime); + Assert.True(lifetime.Physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot placed)); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + placed.Token)); + } + + [Fact] + public void RejectedPreparationKeepsValidatedMoverAndExactTokenRetryable() + { + PhysicsEngine engine = FlatEngine(SourceLandblock, 0f); + using var lifetime = new RuntimeEntityObjectLifetime(engine); + RuntimeEntityRecord record = CreateRecord(lifetime, 0x70001122u, 1); + _ = AttachBody(lifetime, record, SourceCell); + ImmutableArray validated = + [ + new FlatCollisionSphere(new Vector3(0.25f, 0f, 0f), 0.3f), + new FlatCollisionSphere(new Vector3(-0.2f, 0f, 0.4f), 0.2f), + ]; + RuntimeSetPositionOutcome seeded = lifetime.Physics.SetPosition.Apply( + record, + record.PositionAuthorityVersion, + Command(Request( + SourceCell, + new Vector3(12f, 18f, 7f)) with + { + Spheres = validated, + })); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + seeded.Projection)); + Assert.True(lifetime.Physics.SetPosition.TryGetPreparedMoverSphereCount( + record, + out int sphereCount)); + Assert.Equal(2, sphereCount); + + RuntimeEntityPlacementToken token = lifetime.Physics.SetPosition + .BeginAcceptedPlacement( + record, + record.PositionAuthorityVersion, + RuntimeSetPositionOperationKind.RemoteAuthoritative); + RuntimeSetPositionCommand replacement = Command(Request( + SourceCell, + new Vector3(13f, 18f, 7f)) with + { + Spheres = + [ + new FlatCollisionSphere(Vector3.Zero, 0.4f), + ], + }); + engine.TransitionCellCollisionTestHook = + static (_, _, _, _) => TransitionState.Collided; + RuntimeSetPositionOutcome rejected = lifetime.Physics.SetPosition + .SubmitPreparedPlacement(token, replacement); + Assert.Equal(RuntimeSetPositionStatus.Rejected, rejected.Status); + Assert.True(lifetime.Physics.SetPosition.TryGetPreparedMoverSphereCount( + record, + out sphereCount)); + Assert.Equal(2, sphereCount); + + RuntimeSetPositionOutcome malformed = lifetime.Physics.SetPosition + .SubmitPreparedPlacement( + token, + replacement with { GameTime = double.NaN }); + Assert.Equal(PhysicsSetPositionError.InvalidArguments, malformed.Error); + Assert.True(lifetime.Physics.SetPosition.TryGetAwaitingPreparationToken( + record, + out RuntimeEntityPlacementToken retryToken)); + Assert.Equal(token, retryToken); + + engine.TransitionCellCollisionTestHook = null; + RuntimeSetPositionOutcome retried = lifetime.Physics.SetPosition + .SubmitPreparedPlacement(retryToken, replacement); + Assert.Equal( + RuntimeSetPositionStatus.CommittedHostAcknowledgementPending, + retried.Status); + Assert.True(lifetime.Physics.SetPosition.TryGetPreparedMoverSphereCount( + record, + out sphereCount)); + Assert.Equal(1, sphereCount); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + retried.Projection)); + } + + [Theory] + [InlineData(0u)] + [InlineData(SourceLandblock | 0x0041u)] + public void InvalidRetailCellFrameRejectsBeforeCoreAndPreparedCache( + uint invalidCellId) + { + PhysicsEngine engine = FlatEngine(SourceLandblock, 0f); + using var lifetime = new RuntimeEntityObjectLifetime(engine); + RuntimeEntityRecord record = CreateRecord(lifetime, 0x70001128u, 1); + _ = AttachBody(lifetime, record, SourceCell); + RuntimeEntityPlacementToken token = lifetime.Physics.SetPosition + .BeginAcceptedPlacement( + record, + record.PositionAuthorityVersion, + RuntimeSetPositionOperationKind.RemoteAuthoritative); + int collisionCalls = 0; + engine.TransitionCellCollisionTestHook = (_, _, _, _) => + { + collisionCalls++; + return TransitionState.OK; + }; + + RuntimeSetPositionOutcome rejected = lifetime.Physics.SetPosition + .SubmitPreparedPlacement( + token, + Command(Request( + invalidCellId, + new Vector3(12f, 18f, 7f)))); + + Assert.Equal(RuntimeSetPositionStatus.Rejected, rejected.Status); + Assert.Equal(PhysicsSetPositionError.InvalidArguments, rejected.Error); + Assert.Equal(0, collisionCalls); + Assert.False(lifetime.Physics.SetPosition + .TryGetPreparedMoverSphereCount(record, out _)); + Assert.True(lifetime.Physics.SetPosition.TryGetAwaitingPreparationToken( + record, + out RuntimeEntityPlacementToken retry)); + Assert.Equal(token, retry); + } + + [Fact] + public void InvalidRetailQuaternionRejectsBeforeCoreAndPreparedCache() + { + PhysicsEngine engine = FlatEngine(SourceLandblock, 0f); + using var lifetime = new RuntimeEntityObjectLifetime(engine); + RuntimeEntityRecord record = CreateRecord(lifetime, 0x70001129u, 1); + _ = AttachBody(lifetime, record, SourceCell); + RuntimeEntityPlacementToken token = lifetime.Physics.SetPosition + .BeginAcceptedPlacement( + record, + record.PositionAuthorityVersion, + RuntimeSetPositionOperationKind.RemoteAuthoritative); + int collisionCalls = 0; + engine.TransitionCellCollisionTestHook = (_, _, _, _) => + { + collisionCalls++; + return TransitionState.OK; + }; + Quaternion[] invalid = + [ + Quaternion.Zero, + new Quaternion(0f, 0f, 0f, 0.9f), + ]; + + foreach (Quaternion orientation in invalid) + { + RuntimeSetPositionOutcome rejected = lifetime.Physics.SetPosition + .SubmitPreparedPlacement( + token, + Command(Request( + SourceCell, + new Vector3(12f, 18f, 7f)) with + { + Orientation = orientation, + })); + Assert.Equal(RuntimeSetPositionStatus.Rejected, rejected.Status); + Assert.Equal( + PhysicsSetPositionError.InvalidArguments, + rejected.Error); + } + + Assert.Equal(0, collisionCalls); + Assert.False(lifetime.Physics.SetPosition + .TryGetPreparedMoverSphereCount(record, out _)); + Assert.True(lifetime.Physics.SetPosition.TryGetAwaitingPreparationToken( + record, + out RuntimeEntityPlacementToken retry)); + Assert.Equal(token, retry); + } + + [Fact] + public void ExtremeScatterAttemptsRejectWithoutEnteringSynchronousLoop() + { + PhysicsEngine engine = FlatEngine(SourceLandblock, 0f); + using var lifetime = new RuntimeEntityObjectLifetime(engine); + RuntimeEntityRecord record = CreateRecord(lifetime, 0x7000112Au, 1); + _ = AttachBody(lifetime, record, SourceCell); + RuntimeEntityPlacementToken token = lifetime.Physics.SetPosition + .BeginAcceptedPlacement( + record, + record.PositionAuthorityVersion, + RuntimeSetPositionOperationKind.RemoteAuthoritative); + int randomDraws = 0; + engine.SetPositionRandomUnit = () => + { + randomDraws++; + return 0.5d; + }; + + RuntimeSetPositionOutcome rejected = lifetime.Physics.SetPosition + .SubmitPreparedPlacement( + token, + Command(Request( + SourceCell, + new Vector3(12f, 18f, 7f)) with + { + Flags = PhysicsSetPositionFlags.RandomScatter, + ScatterRadiusX = 1f, + ScatterRadiusY = 1f, + ScatterAttempts = uint.MaxValue, + })); + + Assert.Equal(RuntimeSetPositionStatus.Rejected, rejected.Status); + Assert.Equal(PhysicsSetPositionError.InvalidArguments, rejected.Error); + Assert.Equal(0, randomDraws); + Assert.False(lifetime.Physics.SetPosition + .TryGetPreparedMoverSphereCount(record, out _)); + Assert.True(lifetime.Physics.SetPosition.TryGetAwaitingPreparationToken( + record, + out RuntimeEntityPlacementToken retry)); + Assert.Equal(token, retry); + } + + [Fact] + public void BodylessWithdrawalCancellationDoesNotInventBodyOrProjection() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimeEntityRecord record = CreateRecord(lifetime, 0x70001123u, 1); + RuntimeEntityPlacementToken token = lifetime.Physics.SetPosition + .BeginAcceptedPlacement( + record, + record.PositionAuthorityVersion, + RuntimeSetPositionOperationKind.RemoteAuthoritative); + Assert.True(token.IsValid); + + Assert.True(lifetime.Physics.SetPosition.Cancel( + record, + publishWithdrawal: true)); + + Assert.Null(record.PhysicsBody); + Assert.False(lifetime.Physics.SetPosition.TryPeekProjection(out _)); + RuntimeSetPositionOwnershipSnapshot ownership = lifetime.Physics + .SetPosition.CaptureOwnership(); + Assert.Equal(0, ownership.ActiveOperationCount); + Assert.Equal(0, ownership.PendingProjectionAcknowledgementCount); + Assert.True(ownership.IndexesConsistent); + } + + [Fact] + public void ZeroExpectedVelocityPreservesBeginVersionAcrossInterveningVector() + { + PhysicsEngine engine = FlatEngine(SourceLandblock, 0f); + using var lifetime = new RuntimeEntityObjectLifetime(engine); + RuntimeEntityRecord record = CreateRecord(lifetime, 0x70001124u, 1); + PhysicsBody body = AttachBody(lifetime, record, SourceCell); + RuntimeEntityPlacementToken token = lifetime.Physics.SetPosition + .BeginAcceptedPlacement( + record, + record.PositionAuthorityVersion, + RuntimeSetPositionOperationKind.RemoteAuthoritative); + lifetime.Entities.AdvanceVectorAuthority(record); + body.set_velocity(new Vector3(-2f, 0f, 0f)); + engine.TransitionCellCollisionTestHook = + (transition, phase, _, observed) => + { + if (phase is TransitionCellCollisionPhase.Environment) + transition.CollisionInfo.SetCollisionNormal(Vector3.UnitX); + return observed; + }; + + RuntimeSetPositionOutcome outcome = lifetime.Physics.SetPosition + .SubmitPreparedPlacement( + token, + Command(Request( + SourceCell, + new Vector3(14f, 18f, 7f)))); + + Assert.Equal( + RuntimeSetPositionStatus.CommittedHostAcknowledgementPending, + outcome.Status); + Assert.Equal(new Vector3(-2f, 0f, 0f), body.Velocity); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + outcome.Projection)); + } + + [Fact] + public void CanonicalCommitReplacesDerivedBitsAndPublishesSlopeNormal() + { + PhysicsEngine engine = FlatEngine(SourceLandblock, 0f); + using var lifetime = new RuntimeEntityObjectLifetime(engine); + RuntimeEntityRecord record = CreateRecord(lifetime, 0x70001125u, 1); + PhysicsBody body = AttachBody(lifetime, record, SourceCell); + body.TransientState |= TransientStateFlags.Sliding + | TransientStateFlags.StationaryStop + | TransientStateFlags.StationaryStuck + | TransientStateFlags.WaterContact; + body.SlidingNormal = Vector3.UnitY; + Vector3 slope = Vector3.Normalize(new Vector3(0.2f, 0f, 1f)); + engine.TransitionCellCollisionTestHook = + (transition, phase, _, observed) => + { + if (phase is TransitionCellCollisionPhase.Environment) + { + transition.CollisionInfo.SetContactPlane( + new Plane(slope, 0f), + SourceCell); + transition.CollisionInfo.FramesStationaryFall = 1; + } + return observed; + }; + + RuntimeSetPositionOutcome outcome = lifetime.Physics.SetPosition.Apply( + record, + record.PositionAuthorityVersion, + Command(Request( + SourceCell, + new Vector3(15f, 18f, 7f)))); + + Assert.Equal( + RuntimeSetPositionStatus.CommittedHostAcknowledgementPending, + outcome.Status); + Assert.Equal(slope, body.GroundNormal); + Assert.False(body.TransientState.HasFlag(TransientStateFlags.Sliding)); + Assert.Equal(Vector3.Zero, body.SlidingNormal); + Assert.True(body.TransientState.HasFlag( + TransientStateFlags.StationaryFall)); + Assert.False(body.TransientState.HasFlag( + TransientStateFlags.StationaryStop)); + Assert.False(body.TransientState.HasFlag( + TransientStateFlags.StationaryStuck)); + Assert.False(body.TransientState.HasFlag( + TransientStateFlags.WaterContact)); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + outcome.Projection)); + } + + [Fact] + public void ReentrantNewPlacementConvertsPublishedTokenToOrderedDiscard() + { + PhysicsEngine engine = FlatEngine(SourceLandblock, 0f); + using var lifetime = new RuntimeEntityObjectLifetime(engine); + RuntimeEntityRecord record = CreateRecord(lifetime, 0x70001004u, 1); + _ = AttachBody(lifetime, record, SourceCell); + RuntimeEntityPlacementToken replacement = default; + var observer = new PlacementObserver(delta => + { + if (delta.Placement.Kind is RuntimePlacementProjectionKind.Place) + { + replacement = lifetime.Physics.SetPosition + .BeginAcceptedPlacement( + record, + record.PositionAuthorityVersion, + RuntimeSetPositionOperationKind.RemoteAuthoritative); + } + }); + using IDisposable subscription = lifetime.Events.SubscribePlacement(observer); + + RuntimeSetPositionOutcome outcome = lifetime.Physics.SetPosition.Apply( + record, + record.PositionAuthorityVersion, + Command(Request(SourceCell, new Vector3(13f, 18f, 7f)))); + + Assert.True(replacement.IsValid); + Assert.Equal(2, observer.Deltas.Count); + Assert.Equal(RuntimePlacementProjectionKind.Place, + observer.Deltas[0].Placement.Kind); + Assert.Equal(RuntimePlacementProjectionKind.Discard, + observer.Deltas[1].Placement.Kind); + Assert.Equal(outcome.Projection.TokenSequence(), + observer.Deltas[1].Placement.Token.TokenSequence()); + Assert.False(lifetime.Physics.SetPosition.AcknowledgeProjection( + outcome.Projection)); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + observer.Deltas[1].Placement.Token)); + lifetime.Physics.SetPosition.Forget( + record, + releasePreparedMover: true); + Assert.True(lifetime.Physics.SetPosition.CaptureOwnership().IsConverged); + } + + [Fact] + public void ReentrantGroundEdgePlacementCannotBeCancelledByDisplacedOperation() + { + PhysicsEngine engine = FlatEngine(SourceLandblock, 0f); + engine.TransitionCellCollisionTestHook = + (transition, phase, _, observed) => + { + if (phase == TransitionCellCollisionPhase.Environment) + { + transition.CollisionInfo.SetContactPlane( + new Plane(Vector3.UnitZ, 0f), + SourceCell); + } + return observed; + }; + using var lifetime = new RuntimeEntityObjectLifetime(engine); + RuntimeEntityRecord record = CreateRecord(lifetime, 0x70001042u, 1); + PhysicsBody body = AttachBody(lifetime, record, SourceCell); + engine.ShadowObjects.Register( + record.Key!.Value.LocalEntityId, + 0x01000001u, + body.Position, + body.Orientation, + 0.48f, + 0f, + 0f, + SourceLandblock, + seedCellId: SourceCell, + isStatic: false); + RuntimeEntityPlacementToken replacement = default; + var remote = new ReentrantRemotePlacement(body) + { + CellId = SourceCell, + OnHitGround = () => + { + replacement = lifetime.Physics.SetPosition + .BeginAcceptedPlacement( + record, + record.PositionAuthorityVersion, + RuntimeSetPositionOperationKind.RemoteAuthoritative); + }, + }; + lifetime.Entities.SetRemoteMotion(record, remote); + + RuntimeSetPositionOutcome displaced = lifetime.Physics.SetPosition.Apply( + record, + record.PositionAuthorityVersion, + Command(Request(SourceCell, new Vector3(13f, 18f, 7f)))); + + Assert.Equal(RuntimeSetPositionStatus.Cancelled, displaced.Status); + Assert.True(replacement.IsValid); + RuntimeSetPositionOwnershipSnapshot ownership = + lifetime.Physics.SetPosition.CaptureOwnership(); + Assert.Equal(1, ownership.ActiveOperationCount); + Assert.Equal(1, ownership.AwaitingPreparationCount); + Assert.Equal(13f, body.Position.X); + Assert.Equal(18f, body.Position.Y); + Assert.Equal(SourceCell, record.FullCellId); + Assert.True(body.InContact); + Assert.True(body.OnWalkable); + Assert.True(lifetime.Physics.IsSpatialRoot(record)); + ShadowEntry shadow = Assert.Single( + engine.ShadowObjects.AllEntriesForDebug()); + Assert.Equal(body.Position, shadow.Position); + + RuntimeSetPositionOutcome retained = lifetime.Physics.SetPosition + .SubmitPreparedPlacement( + replacement, + Command(Request( + SourceCell, + new Vector3(14f, 18f, 7f)))); + Assert.Equal( + RuntimeSetPositionStatus.CommittedHostAcknowledgementPending, + retained.Status); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + retained.Projection)); + lifetime.Physics.SetPosition.Forget( + record, + releasePreparedMover: true); + Assert.True(lifetime.Physics.SetPosition.CaptureOwnership().IsConverged); + } + + [Fact] + public void RetrySnapshotPreservesOrderWhenObserverAcknowledgesTwoPendingTokens() + { + PhysicsEngine engine = FlatEngine(SourceLandblock, 0f); + using var lifetime = new RuntimeEntityObjectLifetime(engine); + RuntimeEntityRecord first = CreateRecord(lifetime, 0x70001040u, 1); + RuntimeEntityRecord second = CreateRecord(lifetime, 0x70001041u, 1); + _ = AttachBody(lifetime, first, SourceCell); + _ = AttachBody(lifetime, second, SourceCell); + bool acknowledgeRetries = false; + var retryOrder = new List(); + var observer = new PlacementObserver(delta => + { + if (!acknowledgeRetries) + return; + retryOrder.Add(delta.Placement.Token.Sequence); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + delta.Placement.Token)); + }); + using IDisposable subscription = lifetime.Events.SubscribePlacement(observer); + RuntimeSetPositionOutcome firstOutcome = lifetime.Physics.SetPosition.Apply( + first, + first.PositionAuthorityVersion, + Command(Request(SourceCell, new Vector3(14f, 18f, 7f)))); + RuntimeSetPositionOutcome secondOutcome = lifetime.Physics.SetPosition.Apply( + second, + second.PositionAuthorityVersion, + Command(Request(SourceCell, new Vector3(16f, 18f, 7f)))); + + acknowledgeRetries = true; + lifetime.Physics.SetPosition.RetryPendingProjections(); + + Assert.Equal( + [firstOutcome.Projection.Sequence, secondOutcome.Projection.Sequence], + retryOrder); + Assert.Equal(0, lifetime.Physics.CaptureOwnership() + .PendingSetPositionHostAcknowledgementCount); + } + + [Fact] + public void ThrowingObserverDoesNotRollBackCanonicalCommitAndRetryCanAck() + { + PhysicsEngine engine = FlatEngine(SourceLandblock, 0f); + using var lifetime = new RuntimeEntityObjectLifetime(engine); + RuntimeEntityRecord record = CreateRecord(lifetime, 0x70001043u, 1); + PhysicsBody body = AttachBody(lifetime, record, SourceCell); + using IDisposable throwing = lifetime.Events.SubscribePlacement( + new ThrowingPlacementObserver()); + + RuntimeSetPositionOutcome outcome = lifetime.Physics.SetPosition.Apply( + record, + record.PositionAuthorityVersion, + Command(Request(SourceCell, new Vector3(17f, 18f, 7f)))); + + Assert.Equal( + RuntimeSetPositionStatus.CommittedHostAcknowledgementPending, + outcome.Status); + Assert.Equal(new Vector3(17f, 18f, 7f), body.Position); + Assert.Equal(1, lifetime.Events.DispatchFailureCount); + Assert.Equal(1, lifetime.Physics.CaptureOwnership() + .PendingSetPositionHostAcknowledgementCount); + + throwing.Dispose(); + var healthy = new PlacementObserver(); + using IDisposable subscription = lifetime.Events.SubscribePlacement(healthy); + lifetime.Physics.SetPosition.RetryPendingProjections(); + Assert.Equal(outcome.Projection, + Assert.Single(healthy.Deltas).Placement.Token); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + outcome.Projection)); + } + + [Fact] + public void ForgetRevisesPendingTokenAndRetryPublishesDiscardBeforeAck() + { + PhysicsEngine engine = FlatEngine(SourceLandblock, 0f); + using var lifetime = new RuntimeEntityObjectLifetime(engine); + RuntimeEntityRecord record = CreateRecord(lifetime, 0x70001117u, 1); + _ = AttachBody(lifetime, record, SourceCell); + var observer = new PlacementObserver(); + using IDisposable subscription = lifetime.Events.SubscribePlacement(observer); + RuntimeSetPositionOutcome pending = lifetime.Physics.SetPosition.Apply( + record, + record.PositionAuthorityVersion, + Command(Request(SourceCell, new Vector3(17f, 17f, 7f)))); + + RuntimePlacementCancellationReceipt cancellation = + lifetime.Physics.SetPosition.Forget(record); + + Assert.True(cancellation.IsValid); + Assert.Single(observer.Deltas); + Assert.False(lifetime.Physics.SetPosition.AcknowledgeProjection( + pending.Projection)); + lifetime.Physics.SetPosition.RetryPendingProjections(); + RuntimePlacementProjectionSnapshot discard = observer.Deltas[^1].Placement; + Assert.Equal(RuntimePlacementProjectionKind.Discard, discard.Kind); + Assert.Equal(pending.Projection.Sequence, discard.Token.Sequence); + Assert.True(discard.Token.Revision > pending.Projection.Revision); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + discard.Token)); + } + + [Fact] + public void ReplacementRetainsUnacknowledgedLostWithdrawalUntilPreparedCommandCanPlace() + { + PhysicsEngine engine = FlatEngine(SourceLandblock, 0f); + using var lifetime = new RuntimeEntityObjectLifetime(engine); + RuntimeEntityRecord record = CreateRecord(lifetime, 0x7000111Au, 1); + _ = AttachBody(lifetime, record, SourceCell); + using IDisposable throwing = lifetime.Events.SubscribePlacement( + new ThrowingPlacementObserver()); + RuntimeSetPositionOutcome original = lifetime.Physics.SetPosition.Apply( + record, + record.PositionAuthorityVersion, + Command(CrossLandblockRequest())); + Assert.Equal(RuntimeSetPositionStatus.DeferredCell, original.Status); + + RuntimeEntityPlacementToken replacement = lifetime.Physics.SetPosition + .BeginAcceptedPlacement( + record, + record.PositionAuthorityVersion, + RuntimeSetPositionOperationKind.RemoteAuthoritative); + Assert.True(replacement.IsValid); + RuntimeSetPositionOutcome waiting = lifetime.Physics.SetPosition + .SubmitPreparedPlacement( + replacement, + Command(Request(SourceCell, new Vector3(18f, 18f, 7f)))); + Assert.Equal(RuntimeSetPositionStatus.DeferredCell, waiting.Status); + Assert.Equal(original.Projection, waiting.Projection); + Assert.Equal(0u, record.FullCellId); + + throwing.Dispose(); + var healthy = new PlacementObserver(); + using IDisposable subscription = lifetime.Events.SubscribePlacement(healthy); + lifetime.Physics.SetPosition.RetryPendingProjections(); + RuntimePlacementProjectionSnapshot withdrawal = + Assert.Single(healthy.Deltas).Placement; + Assert.Equal(RuntimePlacementProjectionKind.Withdraw, withdrawal.Kind); + Assert.Equal(original.Projection, withdrawal.Token); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + withdrawal.Token)); + + Assert.Equal(2, healthy.Deltas.Count); + RuntimePlacementProjectionSnapshot placed = healthy.Deltas[1].Placement; + Assert.Equal(RuntimePlacementProjectionKind.Place, placed.Kind); + Assert.Equal(SourceCell, record.FullCellId); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + placed.Token)); + } + + [Fact] + public void ReentrantNewerPositionDuringPickupDiscardSuppressesStalePickupDelta() + { + PhysicsEngine engine = FlatEngine(SourceLandblock, 0f); + using var lifetime = new RuntimeEntityObjectLifetime(engine); + RuntimeEntityRecord record = CreateRecord(lifetime, 0x70001118u, 1); + _ = AttachBody(lifetime, record, SourceCell); + RuntimeSetPositionOutcome pending = lifetime.Physics.SetPosition.Apply( + record, + record.PositionAuthorityVersion, + Command(Request(SourceCell, new Vector3(17f, 16f, 7f)))); + var entities = new EntityObserver(); + using IDisposable entitySubscription = lifetime.Events.Subscribe(entities); + bool reentered = false; + RuntimeEventStamp discardStamp = default; + var placements = new PlacementObserver(delta => + { + if (reentered + || delta.Placement.Kind + is not RuntimePlacementProjectionKind.Discard) + { + return; + } + reentered = true; + discardStamp = delta.Stamp; + Assert.True(lifetime.TryApplyPosition( + new WorldSession.EntityPositionUpdate( + record.ServerGuid, + new CreateObject.ServerPosition( + SourceCell, 12f, 20f, 7f, 1f, 0f, 0f, 0f), + Velocity: null, + PlacementId: null, + IsGrounded: true, + InstanceSequence: record.Incarnation, + PositionSequence: 3, + TeleportSequence: 0, + ForcePositionSequence: 0), + isLocalPlayer: false, + forcePositionRotation: null, + currentLocalVelocity: null, + projectionRequiresTeleportHook: false, + acknowledgeProjection: null, + out _, + out _, + out _)); + }); + using IDisposable placementSubscription = + lifetime.Events.SubscribePlacement(placements); + + Assert.False(lifetime.TryApplyPickup( + new PickupEvent.Parsed(record.ServerGuid, 1, 2), + acknowledgeProjection: null, + out _)); + + Assert.True(reentered); + RuntimeEntityDelta only = Assert.Single(entities.Deltas); + Assert.Equal(RuntimeEntityChange.Rebucketed, only.Change); + Assert.True(only.Stamp.Sequence > discardStamp.Sequence); + Assert.False(lifetime.Physics.SetPosition.AcknowledgeProjection( + pending.Projection)); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + Assert.Single(placements.Deltas).Placement.Token)); + } + + [Fact] + public void SyntheticWithdrawalAcknowledgementTerminatesOperation() + { + PhysicsEngine engine = FlatEngine(SourceLandblock, 0f); + using var lifetime = new RuntimeEntityObjectLifetime(engine); + RuntimeEntityRecord record = CreateRecord(lifetime, 0x70001044u, 1); + _ = AttachBody(lifetime, record, SourceCell); + var observer = new PlacementObserver(); + using IDisposable subscription = lifetime.Events.SubscribePlacement(observer); + + Assert.True(lifetime.Physics.SetPosition.Cancel( + record, + publishWithdrawal: true)); + RuntimePlacementProjectionToken token = Assert.Single(observer.Deltas) + .Placement.Token; + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection(token)); + Assert.Equal(0, lifetime.Physics.SetPosition.CaptureOwnership() + .ActiveOperationCount); + } + + [Fact] + public void ReentrantBeginDuringDiscardCannotBeOverwrittenByOuterBegin() + { + PhysicsEngine engine = FlatEngine(SourceLandblock, 0f); + using var lifetime = new RuntimeEntityObjectLifetime(engine); + RuntimeEntityRecord record = CreateRecord(lifetime, 0x70001045u, 1); + _ = AttachBody(lifetime, record, SourceCell); + RuntimeSetPositionOutcome pending = lifetime.Physics.SetPosition.Apply( + record, + record.PositionAuthorityVersion, + Command(Request(SourceCell, new Vector3(18f, 18f, 7f)))); + RuntimeEntityPlacementToken inner = default; + var observer = new PlacementObserver(delta => + { + if (delta.Placement.Kind is RuntimePlacementProjectionKind.Discard) + { + inner = lifetime.Physics.SetPosition.BeginAcceptedPlacement( + record, + record.PositionAuthorityVersion, + RuntimeSetPositionOperationKind.RemoteAuthoritative); + } + }); + using IDisposable subscription = lifetime.Events.SubscribePlacement(observer); + + RuntimeEntityPlacementToken outer = lifetime.Physics.SetPosition + .BeginAcceptedPlacement( + record, + record.PositionAuthorityVersion, + RuntimeSetPositionOperationKind.RemoteAuthoritative); + + Assert.False(outer.IsValid); + Assert.True(inner.IsValid); + Assert.False(lifetime.Physics.SetPosition.AcknowledgeProjection( + pending.Projection)); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + Assert.Single(observer.Deltas).Placement.Token)); + RuntimeSetPositionOutcome committed = lifetime.Physics.SetPosition + .SubmitPreparedPlacement( + inner, + Command(Request(SourceCell, new Vector3(19f, 18f, 7f)))); + Assert.Equal( + RuntimeSetPositionStatus.CommittedHostAcknowledgementPending, + committed.Status); + } + + [Fact] + public void CollisionRetirementRejectsActivePlacementBeforeResidentMutation() + { + PhysicsEngine engine = FlatEngine(SourceLandblock, 0f); + using var lifetime = new RuntimeEntityObjectLifetime(engine); + RuntimeEntityRecord record = CreateRecord(lifetime, 0x70001046u, 1); + _ = AttachBody(lifetime, record, SourceCell); + RuntimeEntityPlacementToken token = lifetime.Physics.SetPosition + .BeginAcceptedPlacement( + record, + record.PositionAuthorityVersion, + RuntimeSetPositionOperationKind.RemoteAuthoritative); + + Assert.Throws(() => + lifetime.Physics.SetPosition.ParkCollisionResidents( + SourceLandblock, + includeOutdoorCells: true)); + Assert.Equal(SourceCell, record.FullCellId); + Assert.True(lifetime.Physics.IsSpatialRoot(record)); + RuntimeSetPositionOutcome submitted = lifetime.Physics.SetPosition + .SubmitPreparedPlacement( + token, + Command(Request(SourceCell, new Vector3(20f, 18f, 7f)))); + + Assert.Equal( + RuntimeSetPositionStatus.CommittedHostAcknowledgementPending, + submitted.Status); + } + + [Fact] + public void CollisionRetirementRejectsHostAckPendingPlacementBeforeResidentMutation() + { + PhysicsEngine engine = FlatEngine(SourceLandblock, 0f); + using var lifetime = new RuntimeEntityObjectLifetime(engine); + RuntimeEntityRecord record = CreateRecord(lifetime, 0x7000111Bu, 1); + _ = AttachBody(lifetime, record, SourceCell); + RuntimeSetPositionOutcome pending = lifetime.Physics.SetPosition.Apply( + record, + record.PositionAuthorityVersion, + Command(Request(SourceCell, new Vector3(20f, 19f, 7f)))); + + Assert.Throws(() => + lifetime.Physics.SetPosition.ParkCollisionResidents( + SourceLandblock, + includeOutdoorCells: true)); + Assert.Equal(SourceCell, record.FullCellId); + Assert.True(lifetime.Physics.IsSpatialRoot(record)); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + pending.Projection)); + } + + [Fact] + public void CollisionRetirementInstallsEveryRootBeforeWithdrawObserverReentry() + { + PhysicsEngine engine = FlatEngine(SourceLandblock, 0f); + using var lifetime = new RuntimeEntityObjectLifetime(engine); + RuntimeEntityRecord first = CreateRecord(lifetime, 0x7000111Du, 1); + RuntimeEntityRecord second = CreateRecord(lifetime, 0x7000111Eu, 1); + _ = AttachBody(lifetime, first, SourceCell); + _ = AttachBody(lifetime, second, SourceCell); + RuntimeEntityPlacementToken replacement = default; + RuntimeSetPositionOutcome submitted = default; + var observer = new PlacementObserver(delta => + { + if (delta.Placement.Kind is RuntimePlacementProjectionKind.Withdraw + && delta.Placement.Token.Entity == first.Key) + { + replacement = lifetime.Physics.SetPosition + .BeginAcceptedPlacement( + second, + second.PositionAuthorityVersion, + RuntimeSetPositionOperationKind.RemoteAuthoritative); + submitted = lifetime.Physics.SetPosition + .SubmitPreparedPlacement( + replacement, + Command(Request( + SourceCell, + new Vector3(21f, 19f, 7f)))); + } + }); + using IDisposable subscription = lifetime.Events.SubscribePlacement(observer); + + lifetime.Physics.SetPosition.ParkCollisionResidents( + SourceLandblock, + includeOutdoorCells: true); + + Assert.True(replacement.IsValid); + Assert.Equal(RuntimeSetPositionStatus.DeferredCell, submitted.Status); + Assert.Equal(2, observer.Deltas.Count); + Assert.All(observer.Deltas, delta => Assert.Equal( + RuntimePlacementProjectionKind.Withdraw, + delta.Placement.Kind)); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + observer.Deltas[0].Placement.Token)); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + observer.Deltas[1].Placement.Token)); + RuntimePlacementProjectionSnapshot placed = observer.Deltas[^1].Placement; + Assert.Equal(RuntimePlacementProjectionKind.Place, placed.Kind); + Assert.Equal(second.Key, placed.Token.Entity); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + placed.Token)); + } + + [Fact] + public void RootAndCurrentDirectChildOwnIndependentExactLostDeadlines() + { + var time = new ManualTimeProvider(DateTimeOffset.UnixEpoch); + PhysicsEngine engine = FlatEngine(SourceLandblock, 0f); + using var lifetime = new RuntimeEntityObjectLifetime( + engine, + timeProvider: time); + RuntimeEntityRecord root = CreateRecord(lifetime, 0x70001005u, 3); + RuntimeEntityRecord child = CreateRecord(lifetime, 0x70001006u, 9); + _ = AttachBody(lifetime, root, SourceCell); + var relation = new ParentAttachmentRelation( + root.ServerGuid, + child.ServerGuid, + ParentLocation: 1, + PlacementId: 2, + ParentInstanceSequence: root.Incarnation, + ChildPositionSequence: 1); + lifetime.Entities.ParentAttachments.AcceptCreateObjectRelation(relation); + Assert.True(lifetime.Entities.ParentAttachments.CommitProjection(relation)); + + RuntimeSetPositionOutcome deferred = lifetime.Physics.SetPosition.Apply( + root, + root.PositionAuthorityVersion, + Command(CrossLandblockRequest())); + + Assert.Equal(2, lifetime.Physics.CaptureOwnership().LostCellDeadlineCount); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + deferred.Projection)); + time.Advance(TimeSpan.FromSeconds(26)); + lifetime.Physics.SetPosition.TickLostCellDeadlines(); + Assert.Equal(2, lifetime.Physics.CaptureOwnership() + .ExpiredLostCellCount); + + var expired = new HashSet(); + while (lifetime.Physics.SetPosition.TryDequeueExpiredLostCell(out var key)) + expired.Add(key); + Assert.Equal(2, expired.Count); + Assert.Contains(root.Key!.Value, expired); + Assert.Contains(child.Key!.Value, expired); + Assert.Equal(0, lifetime.Physics.CaptureOwnership().LostCellDeadlineCount); + Assert.Equal(0, lifetime.Physics.CaptureOwnership() + .ExpiredLostCellCount); + } + + [Fact] + public void ExpiredRootDeletionDoesNotConsumeIndependentChildExpiry() + { + var time = new ManualTimeProvider(DateTimeOffset.UnixEpoch); + PhysicsEngine engine = FlatEngine(SourceLandblock, 0f); + using var lifetime = new RuntimeEntityObjectLifetime(engine, timeProvider: time); + RuntimeEntityRecord root = CreateRecord(lifetime, 0x70001047u, 1); + RuntimeEntityRecord child = CreateRecord(lifetime, 0x70001048u, 1); + _ = AttachBody(lifetime, root, SourceCell); + CommitParent(lifetime, root, child); + RuntimeSetPositionOutcome deferred = lifetime.Physics.SetPosition.Apply( + root, + root.PositionAuthorityVersion, + Command(CrossLandblockRequest())); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + deferred.Projection)); + time.Advance(TimeSpan.FromSeconds(26)); + lifetime.Physics.SetPosition.TickLostCellDeadlines(); + + Assert.True(lifetime.Physics.SetPosition.TryDequeueExpiredLostCell( + out RuntimeEntityKey expiredRoot)); + Assert.Equal(root.Key, expiredRoot); + lifetime.Physics.SetPosition.Forget(root); + Assert.True(lifetime.Physics.SetPosition.TryDequeueExpiredLostCell( + out RuntimeEntityKey expiredChild)); + Assert.Equal(child.Key, expiredChild); + } + + [Fact] + public void ChildPickupCancelsOnlyItsCapturedLostDeadline() + { + var time = new ManualTimeProvider(DateTimeOffset.UnixEpoch); + PhysicsEngine engine = FlatEngine(SourceLandblock, 0f); + using var lifetime = new RuntimeEntityObjectLifetime(engine, timeProvider: time); + RuntimeEntityRecord root = CreateRecord(lifetime, 0x70001049u, 1); + RuntimeEntityRecord child = CreateRecord(lifetime, 0x7000104Au, 1); + _ = AttachBody(lifetime, root, SourceCell); + CommitParent(lifetime, root, child); + RuntimeSetPositionOutcome deferred = lifetime.Physics.SetPosition.Apply( + root, + root.PositionAuthorityVersion, + Command(CrossLandblockRequest())); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + deferred.Projection)); + + Assert.True(lifetime.TryApplyPickup( + new PickupEvent.Parsed(child.ServerGuid, 1, 2), + acknowledgeProjection: null, + out _)); + time.Advance(TimeSpan.FromSeconds(26)); + lifetime.Physics.SetPosition.TickLostCellDeadlines(); + + Assert.True(lifetime.Physics.SetPosition.TryDequeueExpiredLostCell( + out RuntimeEntityKey only)); + Assert.Equal(root.Key, only); + Assert.False(lifetime.Physics.SetPosition.TryDequeueExpiredLostCell(out _)); + } + + [Fact] + public void LostDeadlineUsesMonotonicTimeAndResetClearsExpiredOwnership() + { + var time = new ManualTimeProvider(DateTimeOffset.UnixEpoch); + PhysicsEngine engine = FlatEngine(SourceLandblock, 0f); + using var lifetime = new RuntimeEntityObjectLifetime(engine, timeProvider: time); + RuntimeEntityRecord record = CreateRecord(lifetime, 0x7000104Bu, 1); + _ = AttachBody(lifetime, record, SourceCell); + RuntimeSetPositionOutcome deferred = lifetime.Physics.SetPosition.Apply( + record, + record.PositionAuthorityVersion, + Command(CrossLandblockRequest())); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + deferred.Projection)); + + time.JumpUtc(TimeSpan.FromDays(30)); + lifetime.Physics.SetPosition.TickLostCellDeadlines(); + Assert.Equal(0, lifetime.Physics.CaptureOwnership() + .ExpiredLostCellCount); + time.Advance(TimeSpan.FromSeconds(26)); + lifetime.Physics.SetPosition.TickLostCellDeadlines(); + Assert.Equal(1, lifetime.Physics.CaptureOwnership() + .ExpiredLostCellCount); + + _ = lifetime.BeginSessionClear(); + Assert.Equal(0, lifetime.Physics.CaptureOwnership() + .ExpiredLostCellCount); + Assert.False(lifetime.Physics.SetPosition.TryDequeueExpiredLostCell(out _)); + } + + [Fact] + public void LostDeadlineTickIsZeroAllocationWhenEmptyAndExpiresByDeadlinePriority() + { + var time = new ManualTimeProvider(DateTimeOffset.UnixEpoch); + PhysicsEngine engine = FlatEngine(SourceLandblock, 0f); + using var lifetime = new RuntimeEntityObjectLifetime( + engine, + timeProvider: time); + lifetime.Physics.SetPosition.TickLostCellDeadlines(); + long before = GC.GetAllocatedBytesForCurrentThread(); + for (int index = 0; index < 1_000; index++) + lifetime.Physics.SetPosition.TickLostCellDeadlines(); + Assert.Equal(0, GC.GetAllocatedBytesForCurrentThread() - before); + + RuntimeEntityRecord first = CreateRecord(lifetime, 0x70001101u, 1); + RuntimeEntityRecord middle = CreateRecord(lifetime, 0x70001102u, 1); + RuntimeEntityRecord last = CreateRecord(lifetime, 0x70001103u, 1); + foreach (RuntimeEntityRecord record in new[] { first, middle, last }) + { + _ = AttachBody(lifetime, record, SourceCell); + RuntimeSetPositionOutcome deferred = lifetime.Physics.SetPosition.Apply( + record, + record.PositionAuthorityVersion, + Command(CrossLandblockRequest())); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + deferred.Projection)); + time.Advance(TimeSpan.FromSeconds(1)); + } + lifetime.Physics.SetPosition.LeaveWorld(middle); + time.Advance(TimeSpan.FromSeconds(24)); + lifetime.Physics.SetPosition.TickLostCellDeadlines(); + + Assert.True(lifetime.Physics.SetPosition.TryDequeueExpiredLostCell( + out RuntimeEntityKey firstExpired)); + Assert.True(lifetime.Physics.SetPosition.TryDequeueExpiredLostCell( + out RuntimeEntityKey secondExpired)); + Assert.Equal(first.Key, firstExpired); + Assert.Equal(last.Key, secondExpired); + Assert.False(lifetime.Physics.SetPosition.TryDequeueExpiredLostCell(out _)); + } + + [Fact] + public void RearmedLostDeadlineSupersedesItsStalePriorityEntry() + { + var time = new ManualTimeProvider(DateTimeOffset.UnixEpoch); + PhysicsEngine engine = FlatEngine(SourceLandblock, 0f); + using var lifetime = new RuntimeEntityObjectLifetime( + engine, + timeProvider: time); + RuntimeEntityRecord first = CreateRecord(lifetime, 0x70001112u, 1); + RuntimeEntityRecord second = CreateRecord(lifetime, 0x70001113u, 1); + _ = AttachBody(lifetime, first, SourceCell); + _ = AttachBody(lifetime, second, SourceCell); + + RuntimeSetPositionOutcome firstLost = lifetime.Physics.SetPosition.Apply( + first, + first.PositionAuthorityVersion, + Command(CrossLandblockRequest())); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + firstLost.Projection)); + time.Advance(TimeSpan.FromSeconds(1)); + RuntimeSetPositionOutcome secondLost = lifetime.Physics.SetPosition.Apply( + second, + second.PositionAuthorityVersion, + Command(CrossLandblockRequest())); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + secondLost.Projection)); + time.Advance(TimeSpan.FromSeconds(1)); + + RuntimeEntityPlacementToken replacement = lifetime.Physics.SetPosition + .BeginAcceptedPlacement( + first, + first.PositionAuthorityVersion, + RuntimeSetPositionOperationKind.RemoteAuthoritative); + RuntimeSetPositionOutcome rearmed = lifetime.Physics.SetPosition + .SubmitPreparedPlacement(replacement, Command(CrossLandblockRequest())); + Assert.Equal(RuntimeSetPositionStatus.DeferredCell, rearmed.Status); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + rearmed.Projection)); + + time.Advance(TimeSpan.FromSeconds(24.5)); + lifetime.Physics.SetPosition.TickLostCellDeadlines(); + Assert.True(lifetime.Physics.SetPosition.TryDequeueExpiredLostCell( + out RuntimeEntityKey expired)); + Assert.Equal(second.Key, expired); + Assert.False(lifetime.Physics.SetPosition.TryDequeueExpiredLostCell(out _)); + time.Advance(TimeSpan.FromSeconds(1)); + lifetime.Physics.SetPosition.TickLostCellDeadlines(); + Assert.True(lifetime.Physics.SetPosition.TryDequeueExpiredLostCell( + out expired)); + Assert.Equal(first.Key, expired); + } + + [Fact] + public void RepeatedLostDeadlineRearmAndCancelKeepsExactHeapBounded() + { + PhysicsEngine engine = FlatEngine(SourceLandblock, 0f); + using var lifetime = new RuntimeEntityObjectLifetime(engine); + RuntimeEntityRecord record = CreateRecord(lifetime, 0x70001119u, 1); + _ = AttachBody(lifetime, record, SourceCell); + RuntimeSetPositionOutcome deferred = lifetime.Physics.SetPosition.Apply( + record, + record.PositionAuthorityVersion, + Command(CrossLandblockRequest())); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + deferred.Projection)); + + for (int iteration = 0; iteration < 100; iteration++) + { + RuntimeEntityPlacementToken token = lifetime.Physics.SetPosition + .BeginAcceptedPlacement( + record, + record.PositionAuthorityVersion, + RuntimeSetPositionOperationKind.RemoteAuthoritative); + deferred = lifetime.Physics.SetPosition.SubmitPreparedPlacement( + token, + Command(CrossLandblockRequest())); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + deferred.Projection)); + RuntimeSetPositionOwnershipSnapshot snapshot = lifetime.Physics + .SetPosition.CaptureOwnership(); + Assert.True(snapshot.IndexesConsistent); + Assert.Equal(1, snapshot.LostDeadlineCount); + Assert.Equal(1, snapshot.LostDeadlineNodeCount); + Assert.Equal(1, snapshot.LostDeadlineIndexCount); + } + + lifetime.Physics.SetPosition.LeaveWorld(record); + RuntimeSetPositionOwnershipSnapshot final = lifetime.Physics + .SetPosition.CaptureOwnership(); + Assert.True(final.IndexesConsistent); + Assert.Equal(0, final.LostDeadlineCount); + Assert.Equal(0, final.LostDeadlineNodeCount); + Assert.Equal(0, final.LostDeadlineIndexCount); + } + + [Fact] + public void CommittedGenerationWithoutExactIndoorCellRebindsAndWakesOnNextGeneration() + { + PhysicsEngine engine = FlatEngine(SourceLandblock, 0f); + using var lifetime = new RuntimeEntityObjectLifetime(engine); + RuntimeEntityRecord record = CreateRecord(lifetime, 0x70001114u, 1); + _ = AttachBody(lifetime, record, SourceCell); + var observer = new PlacementObserver(); + using IDisposable subscription = lifetime.Events.SubscribePlacement(observer); + RuntimeSetPositionOutcome deferred = lifetime.Physics.SetPosition.Apply( + record, + record.PositionAuthorityVersion, + Command(Request( + DestinationIndoorCell, + new Vector3(10f, 12f, 7f)))); + Assert.Equal(RuntimeSetPositionStatus.DeferredCell, deferred.Status); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + deferred.Projection)); + + RuntimeCollisionAdmission missing = lifetime.Physics + .BeginCollisionAdmission(DestinationLandblock); + using (PreparedLandblockCollisionGeneration prepared = + lifetime.Physics.PrepareCollisionGeneration(missing)) + { + lifetime.Physics.StageCollisionAssets( + missing, + prepared, + CollisionAssets(DestinationLandblock)); + Assert.True(CommitPrepared( + lifetime.Physics, + missing, + prepared).Committed); + } + Assert.Single(observer.Deltas); + RuntimePhysicsOwnershipSnapshot unbound = lifetime.Physics + .CaptureOwnership(); + Assert.Equal(1, unbound.UnboundDeferredSetPositionCellCount); + Assert.Equal(1, unbound.UnboundDeferredSetPositionCellOrderCount); + + RuntimeCollisionAdmission resident = lifetime.Physics + .BeginCollisionAdmission(DestinationLandblock); + using PreparedLandblockCollisionGeneration preparedResident = + lifetime.Physics.PrepareCollisionGeneration(resident); + lifetime.Physics.StageCollisionAssets( + resident, + preparedResident, + CollisionAssets(DestinationLandblock)); + AddSyntheticCell(preparedResident.DataCache, DestinationIndoorCell); + Assert.True(CommitPrepared( + lifetime.Physics, + resident, + preparedResident).Committed); + + RuntimePlacementProjectionSnapshot placed = observer.Deltas[^1].Placement; + Assert.Equal(RuntimePlacementProjectionKind.Place, placed.Kind); + Assert.Equal(DestinationIndoorCell, placed.Token.ExactCellId); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + placed.Token)); + RuntimePhysicsOwnershipSnapshot final = lifetime.Physics.CaptureOwnership(); + Assert.Equal(0, final.DeferredSetPositionBucketCount); + Assert.Equal(0, final.UnboundDeferredSetPositionCellCount); + } + + [Fact] + public void CollisionAdmissionSupersessionAndInvalidationPreserveDeferredSurvivorOrder() + { + PhysicsEngine engine = FlatEngine(SourceLandblock, 0f); + using var lifetime = new RuntimeEntityObjectLifetime(engine); + RuntimeEntityRecord first = CreateRecord(lifetime, 0x70001115u, 1); + RuntimeEntityRecord second = CreateRecord(lifetime, 0x70001116u, 1); + _ = AttachBody(lifetime, first, SourceCell); + _ = AttachBody(lifetime, second, SourceCell); + var observer = new PlacementObserver(); + using IDisposable subscription = lifetime.Events.SubscribePlacement(observer); + foreach (RuntimeEntityRecord record in new[] { first, second }) + { + RuntimeSetPositionOutcome deferred = lifetime.Physics.SetPosition.Apply( + record, + record.PositionAuthorityVersion, + Command(Request( + DestinationIndoorCell, + new Vector3(10f, 12f, 7f)))); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + deferred.Projection)); + } + observer.Deltas.Clear(); + + RuntimeCollisionAdmission superseded = lifetime.Physics + .BeginCollisionAdmission(DestinationLandblock); + RuntimeCollisionAdmission invalidated = lifetime.Physics + .BeginCollisionAdmission(DestinationLandblock); + Assert.Throws(() => + lifetime.Physics.PrepareCollisionGeneration(superseded)); + lifetime.Physics.CancelCollisionGeneration(invalidated); + RuntimeCollisionAdmission current = lifetime.Physics + .BeginCollisionAdmission(DestinationLandblock); + using PreparedLandblockCollisionGeneration prepared = lifetime.Physics + .PrepareCollisionGeneration(current); + lifetime.Physics.StageCollisionAssets( + current, + prepared, + CollisionAssets(DestinationLandblock)); + AddSyntheticCell(prepared.DataCache, DestinationIndoorCell); + Assert.True(CommitPrepared( + lifetime.Physics, + current, + prepared).Committed); + + Assert.Equal(2, observer.Deltas.Count); + Assert.Equal(first.Key, observer.Deltas[0].Placement.Token.Entity); + Assert.Equal(second.Key, observer.Deltas[1].Placement.Token.Entity); + Assert.All(observer.Deltas, delta => Assert.Equal( + RuntimePlacementProjectionKind.Place, + delta.Placement.Kind)); + Assert.True(lifetime.Physics.CaptureOwnership() + .DeferredSetPositionBucketCount == 0); + } + + [Fact] + public void NextAdmissionMergesOlderUnboundAndNewerFutureBoundSurvivors() + { + PhysicsEngine engine = FlatEngine(SourceLandblock, 0f); + using var lifetime = new RuntimeEntityObjectLifetime(engine); + RuntimeEntityRecord older = CreateRecord(lifetime, 0x7000111Fu, 1); + RuntimeEntityRecord newer = CreateRecord(lifetime, 0x70001120u, 1); + _ = AttachBody(lifetime, older, SourceCell); + _ = AttachBody(lifetime, newer, SourceCell); + var observer = new PlacementObserver(); + using IDisposable subscription = lifetime.Events.SubscribePlacement(observer); + + RuntimeSetPositionOutcome olderLost = lifetime.Physics.SetPosition.Apply( + older, + older.PositionAuthorityVersion, + Command(Request( + DestinationIndoorCell, + new Vector3(10f, 12f, 7f)))); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + olderLost.Projection)); + RuntimeCollisionAdmission cancelled = lifetime.Physics + .BeginCollisionAdmission(DestinationLandblock); + lifetime.Physics.CancelCollisionGeneration(cancelled); + + RuntimeSetPositionOutcome newerLost = lifetime.Physics.SetPosition.Apply( + newer, + newer.PositionAuthorityVersion, + Command(Request( + DestinationIndoorCell, + new Vector3(11f, 12f, 7f)))); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + newerLost.Projection)); + RuntimePhysicsOwnershipSnapshot split = lifetime.Physics.CaptureOwnership(); + Assert.Equal(1, split.UnboundDeferredSetPositionCellCount); + Assert.Equal(1, split.DeferredSetPositionBucketCount); + observer.Deltas.Clear(); + + RuntimeCollisionAdmission current = lifetime.Physics + .BeginCollisionAdmission(DestinationLandblock); + RuntimePhysicsOwnershipSnapshot merged = lifetime.Physics.CaptureOwnership(); + Assert.Equal(0, merged.UnboundDeferredSetPositionCellCount); + Assert.Equal(1, merged.DeferredSetPositionBucketCount); + using PreparedLandblockCollisionGeneration prepared = lifetime.Physics + .PrepareCollisionGeneration(current); + lifetime.Physics.StageCollisionAssets( + current, + prepared, + CollisionAssets(DestinationLandblock)); + AddSyntheticCell(prepared.DataCache, DestinationIndoorCell); + Assert.True(CommitPrepared( + lifetime.Physics, + current, + prepared).Committed); + + Assert.Equal(2, observer.Deltas.Count); + Assert.Equal(older.Key, observer.Deltas[0].Placement.Token.Entity); + Assert.Equal(newer.Key, observer.Deltas[1].Placement.Token.Entity); + Assert.All(observer.Deltas, delta => Assert.Equal( + RuntimePlacementProjectionKind.Place, + delta.Placement.Kind)); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + observer.Deltas[0].Placement.Token)); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + observer.Deltas[1].Placement.Token)); + } + + [Theory] + [InlineData(false, 0x0101u)] + [InlineData(true, 0x0001u)] + public void CollisionRetirementParksOnlyAffectedDynamicParentlessRoots( + bool fullWithdrawal, + uint lowCell) + { + const uint landblock = 0x01010000u; + PhysicsEngine engine = FlatEngine(landblock, 0f); + using var lifetime = new RuntimeEntityObjectLifetime(engine); + RuntimeEntityRecord dynamicRoot = CreateRecord(lifetime, 0x70001007u, 1); + uint cell = landblock | lowCell; + PhysicsBody dynamicBody = AttachBody( + lifetime, + dynamicRoot, + landblock | 0x0001u); + ImmutableArray authoredSpheres = + [ + new FlatCollisionSphere(new Vector3(0.25f, 0f, 0f), 0.3f), + new FlatCollisionSphere(new Vector3(-0.2f, 0.1f, 0.4f), 0.2f), + ]; + RuntimeSetPositionOutcome prepared = lifetime.Physics.SetPosition.Apply( + dynamicRoot, + dynamicRoot.PositionAuthorityVersion, + Command(Request( + landblock | 0x0001u, + new Vector3(12f, 18f, 7f)) with + { + Spheres = authoredSpheres, + })); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + prepared.Projection)); + Assert.True(lifetime.Physics.SetPosition.TryGetPreparedMoverSphereCount( + dynamicRoot, + out int sphereCount)); + Assert.Equal(2, sphereCount); + lifetime.Entities.SetFullCell( + dynamicRoot, + cell, + landblock | 0xFFFFu); + dynamicBody.SnapToCell(cell, dynamicBody.Position, dynamicBody.Position); + RuntimeEntityRecord staticRoot = CreateRecord(lifetime, 0x70001008u, 1); + _ = AttachBody(lifetime, staticRoot, cell, PhysicsStateFlags.Static); + RuntimeEntityRecord parent = CreateRecord(lifetime, 0x7000104Cu, 1); + _ = AttachBody(lifetime, parent, SourceCell); + RuntimeEntityRecord attached = CreateRecord(lifetime, 0x7000104Du, 1); + _ = AttachBody(lifetime, attached, cell); + CommitParent(lifetime, parent, attached); + + lifetime.Physics.SetPosition.ParkCollisionResidents( + landblock, + includeOutdoorCells: fullWithdrawal); + + Assert.True(lifetime.Physics.SetPosition.IsDeferred(dynamicRoot)); + Assert.Equal(0, lifetime.Physics.CaptureOwnership() + .AwaitingSetPositionPreparationCount); + Assert.Equal(0u, dynamicRoot.FullCellId); + Assert.False(lifetime.Physics.IsSpatialRoot(dynamicRoot)); + Assert.False(lifetime.Physics.SetPosition.IsDeferred(staticRoot)); + Assert.Equal(cell, staticRoot.FullCellId); + Assert.True(lifetime.Physics.IsSpatialRoot(staticRoot)); + Assert.False(lifetime.Physics.SetPosition.IsDeferred(attached)); + Assert.Equal(cell, attached.FullCellId); + + lifetime.Entities.ParentAttachments.EndChildProjection( + attached.ServerGuid); + lifetime.Physics.SetPosition.ParkCollisionResidents( + landblock, + includeOutdoorCells: fullWithdrawal); + Assert.True(lifetime.Physics.SetPosition.IsDeferred(attached)); + } + + [Fact] + public void PickupStyleLeaveWorldCancelsLostWakeAndTerminalDisposeConverges() + { + PhysicsEngine engine = FlatEngine(SourceLandblock, 0f); + var lifetime = new RuntimeEntityObjectLifetime(engine); + RuntimeEntityRecord record = CreateRecord(lifetime, 0x70001009u, 1); + PhysicsBody body = AttachBody(lifetime, record, SourceCell); + RuntimeSetPositionOutcome deferred = lifetime.Physics.SetPosition.Apply( + record, + record.PositionAuthorityVersion, + Command(CrossLandblockRequest())); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + deferred.Projection)); + + lifetime.Physics.SetPosition.LeaveWorld(record); + AddFlatLandblock(engine, DestinationLandblock, 192f); + lifetime.Physics.SetPosition.CommitCollisionGeneration( + DestinationLandblock, + generation: 1, + ready: true); + + Assert.Equal(0u, record.FullCellId); + Assert.Equal(0u, body.CellPosition.ObjCellId); + Assert.False(body.InWorld); + Assert.False(lifetime.Physics.SetPosition.IsDeferred(record)); + + lifetime.Dispose(); + Assert.True(lifetime.Physics.CaptureOwnership().IsConverged); + } + + [Fact] + public void CommittedParentDoesNotLeakAcrossChildGuidReuse() + { + const uint childGuid = 0x7000104Eu; + PhysicsEngine engine = FlatEngine(SourceLandblock, 0f); + using var lifetime = new RuntimeEntityObjectLifetime(engine); + RuntimeEntityRecord parent = CreateRecord(lifetime, 0x7000104Fu, 1); + RuntimeEntityRecord retired = CreateRecord(lifetime, childGuid, 1); + _ = AttachBody(lifetime, parent, SourceCell); + _ = AttachBody(lifetime, retired, SourceCell); + CommitParent(lifetime, parent, retired); + Assert.True(lifetime.TryAcceptDelete( + new DeleteObject.Parsed(childGuid, 1), + isLocalPlayer: false, + removeRetainedObject: false, + out RuntimeEntityDeleteAcceptance acceptance)); + lifetime.CompleteAcceptedDelete(acceptance); + Assert.Null(lifetime.RetireCanonicalOnly(retired)); + + RuntimeEntityRecord replacement = CreateRecord(lifetime, childGuid, 2); + _ = AttachBody(lifetime, replacement, SourceCell); + lifetime.Physics.SetPosition.ParkCollisionResidents( + SourceLandblock, + includeOutdoorCells: true); + + Assert.True(lifetime.Physics.SetPosition.IsDeferred(replacement)); + } + + [Fact] + public void NewerPositionPickupAndParentEachCancelExactLostOperation() + { + VerifyPositionChannelCancellation(CancellationChannel.Position); + VerifyPositionChannelCancellation(CancellationChannel.Pickup); + VerifyPositionChannelCancellation(CancellationChannel.Parent); + } + + [Fact] + public void DeleteGuidReuseAndSessionResetCannotWakeStaleIncarnation() + { + PhysicsEngine engine = FlatEngine(SourceLandblock, 0f); + using var lifetime = new RuntimeEntityObjectLifetime(engine); + const uint guid = 0x7000100Du; + RuntimeEntityRecord retired = CreateRecord(lifetime, guid, 1); + _ = AttachBody(lifetime, retired, SourceCell); + RuntimeSetPositionOutcome deferred = lifetime.Physics.SetPosition.Apply( + retired, + retired.PositionAuthorityVersion, + Command(CrossLandblockRequest())); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + deferred.Projection)); + Assert.True(lifetime.TryAcceptDelete( + new DeleteObject.Parsed(guid, 1), + isLocalPlayer: false, + removeRetainedObject: false, + out RuntimeEntityDeleteAcceptance acceptance)); + lifetime.CompleteAcceptedDelete(acceptance); + Assert.Null(lifetime.RetireCanonicalOnly(retired)); + + RuntimeEntityRecord replacement = CreateRecord(lifetime, guid, 2); + PhysicsBody replacementBody = AttachBody( + lifetime, + replacement, + SourceCell); + AddFlatLandblock(engine, DestinationLandblock, 192f); + lifetime.Physics.SetPosition.CommitCollisionGeneration( + DestinationLandblock, + generation: 1, + ready: true); + Assert.Equal(SourceCell, replacement.FullCellId); + Assert.Equal(SourceCell, replacementBody.CellPosition.ObjCellId); + Assert.False(lifetime.Physics.SetPosition.IsDeferred(replacement)); + + RuntimeSetPositionOutcome pending = lifetime.Physics.SetPosition.Apply( + replacement, + replacement.PositionAuthorityVersion, + Command(Request(SourceCell, new Vector3(15f, 18f, 7f)))); + Assert.True(pending.Projection.IsValid); + IReadOnlyList retiring = lifetime.BeginSessionClear(); + Assert.Single(retiring); + Assert.Equal(0, lifetime.Physics.CaptureOwnership() + .SetPositionOperationCount); + Assert.Equal(0, lifetime.Physics.CaptureOwnership() + .PendingSetPositionHostAcknowledgementCount); + } + + [Fact] + public void ColdCollisionRetirementWaitsForExactAuthoredMoverPreparation() + { + const uint landblock = 0x01010000u; + PhysicsEngine engine = FlatEngine(landblock, 0f); + using var lifetime = new RuntimeEntityObjectLifetime(engine); + RuntimeEntityRecord record = CreateRecord(lifetime, 0x7000100Eu, 1); + _ = AttachBody(lifetime, record, landblock | 0x0101u); + + lifetime.Physics.SetPosition.ParkCollisionResidents( + landblock, + includeOutdoorCells: false); + + Assert.True(lifetime.Physics.SetPosition.IsDeferred(record)); + Assert.Equal(1, lifetime.Physics.CaptureOwnership() + .AwaitingSetPositionPreparationCount); + Assert.True(lifetime.Physics.SetPosition.TryGetAwaitingPreparationToken( + record, + out RuntimeEntityPlacementToken token)); + Assert.True(lifetime.Physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot withdrawal)); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + withdrawal.Token)); + ImmutableArray authored = + [ + new FlatCollisionSphere(new Vector3(0.4f, 0f, 0f), 0.35f), + new FlatCollisionSphere(new Vector3(-0.3f, 0f, 0.5f), 0.2f), + ]; + RuntimeSetPositionOutcome supplied = lifetime.Physics.SetPosition + .SubmitPreparedPlacement( + token, + Command(Request( + landblock | 0x0101u, + new Vector3(999f, 999f, 999f)) with + { + Spheres = authored, + })); + + Assert.Equal(RuntimeSetPositionStatus.DeferredCell, supplied.Status); + Assert.Equal(0, lifetime.Physics.CaptureOwnership() + .AwaitingSetPositionPreparationCount); + Assert.False(lifetime.Physics.SetPosition.TryGetPreparedMoverSphereCount( + record, + out _)); + AddSyntheticCell(engine.DataCache!, landblock | 0x0101u); + lifetime.Physics.SetPosition.CommitCollisionGeneration( + landblock, + generation: 1, + ready: true); + Assert.True(lifetime.Physics.SetPosition.TryGetPreparedMoverSphereCount( + record, + out int sphereCount)); + Assert.Equal(2, sphereCount); + Assert.Equal(landblock | 0x0101u, + record.PhysicsBody!.CellPosition.ObjCellId); + Assert.True(lifetime.Physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot placed)); + Assert.Equal(RuntimePlacementProjectionKind.Place, placed.Kind); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + placed.Token)); + } + + [Fact] + public void ColdMalformedWakeReindexesUntilCorrectedPreparationAndNextAdmission() + { + const uint landblock = 0x01010000u; + uint exactCell = landblock | 0x0101u; + PhysicsEngine engine = FlatEngine(landblock, 0f); + using var lifetime = new RuntimeEntityObjectLifetime(engine); + RuntimeEntityRecord record = CreateRecord(lifetime, 0x70001127u, 1); + _ = AttachBody(lifetime, record, exactCell); + var observer = new PlacementObserver(); + using IDisposable subscription = lifetime.Events.SubscribePlacement(observer); + lifetime.Physics.SetPosition.ParkCollisionResidents( + landblock, + includeOutdoorCells: false); + Assert.True(lifetime.Physics.SetPosition.TryGetAwaitingPreparationToken( + record, + out RuntimeEntityPlacementToken token)); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + Assert.Single(observer.Deltas).Placement.Token)); + observer.Deltas.Clear(); + + RuntimeSetPositionCommand malformed = Command(Request( + exactCell, + new Vector3(10f, 18f, 7f)) with + { + Spheres = + [ + new FlatCollisionSphere( + new Vector3(float.NaN, 0f, 0f), + 0.4f), + ], + }); + RuntimeSetPositionOutcome acceptedMalformed = lifetime.Physics + .SetPosition.SubmitPreparedPlacement(token, malformed); + Assert.Equal(RuntimeSetPositionStatus.DeferredCell, + acceptedMalformed.Status); + + RuntimeCollisionAdmission first = lifetime.Physics + .BeginCollisionAdmission(landblock); + using (PreparedLandblockCollisionGeneration prepared = lifetime.Physics + .PrepareCollisionGeneration(first)) + { + lifetime.Physics.StageCollisionAssets( + first, + prepared, + CollisionAssets(landblock)); + AddSyntheticCell(prepared.DataCache, exactCell); + Assert.True(CommitPrepared( + lifetime.Physics, + first, + prepared).Committed); + } + RuntimeSetPositionOwnershipSnapshot invalidWake = lifetime.Physics + .SetPosition.CaptureOwnership(); + Assert.Equal(1, invalidWake.AwaitingPreparationCount); + Assert.Equal(1, invalidWake.DeferredBucketCount); + Assert.Equal(1, invalidWake.LostDeadlineCount); + Assert.Empty(observer.Deltas); + Assert.False(lifetime.Physics.SetPosition.TryGetPreparedMoverSphereCount( + record, + out _)); + + RuntimeSetPositionCommand corrected = Command(Request( + exactCell, + new Vector3(10f, 18f, 7f)) with + { + Spheres = + [ + new FlatCollisionSphere(Vector3.Zero, 0.4f), + ], + }); + RuntimeSetPositionOutcome correctedPending = lifetime.Physics + .SetPosition.SubmitPreparedPlacement(token, corrected); + Assert.Equal(RuntimeSetPositionStatus.DeferredCell, + correctedPending.Status); + Assert.Equal(PhysicsResidenceDisposition.DeferredCell, + correctedPending.Residence); + Assert.Equal(exactCell, correctedPending.ExactCellId); + + RuntimeCollisionAdmission second = lifetime.Physics + .BeginCollisionAdmission(landblock); + using PreparedLandblockCollisionGeneration preparedSecond = lifetime + .Physics.PrepareCollisionGeneration(second); + lifetime.Physics.StageCollisionAssets( + second, + preparedSecond, + CollisionAssets(landblock)); + AddSyntheticCell(preparedSecond.DataCache, exactCell); + Assert.True(CommitPrepared( + lifetime.Physics, + second, + preparedSecond).Committed); + + RuntimePlacementProjectionSnapshot placed = Assert.Single(observer.Deltas) + .Placement; + Assert.Equal(RuntimePlacementProjectionKind.Place, placed.Kind); + Assert.True(lifetime.Physics.SetPosition.TryGetPreparedMoverSphereCount( + record, + out int sphereCount)); + Assert.Equal(1, sphereCount); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + placed.Token)); + } + + private static void VerifyPositionChannelCancellation( + CancellationChannel channel) + { + PhysicsEngine engine = FlatEngine(SourceLandblock, 0f); + using var lifetime = new RuntimeEntityObjectLifetime(engine); + uint guid = 0x7000100Au + (uint)channel; + RuntimeEntityRecord record = CreateRecord(lifetime, guid, 1); + _ = AttachBody(lifetime, record, SourceCell); + RuntimeSetPositionOutcome deferred = lifetime.Physics.SetPosition.Apply( + record, + record.PositionAuthorityVersion, + Command(CrossLandblockRequest())); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection( + deferred.Projection)); + + switch (channel) + { + case CancellationChannel.Position: + Assert.True(lifetime.TryApplyPosition( + new WorldSession.EntityPositionUpdate( + guid, + new CreateObject.ServerPosition( + SourceCell, 11f, 20f, 7f, 1f, 0f, 0f, 0f), + Velocity: null, + PlacementId: null, + IsGrounded: true, + InstanceSequence: 1, + PositionSequence: 2, + TeleportSequence: 0, + ForcePositionSequence: 0), + isLocalPlayer: false, + forcePositionRotation: null, + currentLocalVelocity: null, + projectionRequiresTeleportHook: false, + acknowledgeProjection: null, + out _, + out _, + out _)); + break; + case CancellationChannel.Pickup: + Assert.True(lifetime.TryApplyPickup( + new PickupEvent.Parsed(guid, 1, 2), + acknowledgeProjection: null, + out _)); + break; + case CancellationChannel.Parent: + RuntimeEntityRecord parent = CreateRecord( + lifetime, + guid + 0x100u, + 4); + Assert.True(lifetime.TryApplyParent( + new ParentEvent.Parsed( + parent.ServerGuid, + guid, + ParentLocation: 1, + PlacementId: 2, + ParentInstanceSequence: parent.Incarnation, + ChildPositionSequence: 2), + acknowledgeProjection: null, + out _)); + break; + default: + throw new ArgumentOutOfRangeException(nameof(channel)); + } + + Assert.False(lifetime.Physics.SetPosition.IsDeferred(record)); + Assert.Equal(0, lifetime.Physics.CaptureOwnership() + .DeferredSetPositionCount); + Assert.Equal(0, lifetime.Physics.CaptureOwnership() + .LostCellDeadlineCount); + } + + private static void CommitParent( + RuntimeEntityObjectLifetime lifetime, + RuntimeEntityRecord parent, + RuntimeEntityRecord child) + { + var relation = new ParentAttachmentRelation( + parent.ServerGuid, + child.ServerGuid, + ParentLocation: 1, + PlacementId: 2, + ParentInstanceSequence: parent.Incarnation, + ChildPositionSequence: 1); + lifetime.Entities.ParentAttachments.AcceptCreateObjectRelation(relation); + Assert.True(lifetime.Entities.ParentAttachments.CommitProjection(relation)); + } + + private static RuntimeSetPositionCommand Command( + PhysicsSetPositionRequest request) => new( + request, + RuntimeSetPositionOperationKind.RemoteAuthoritative, + GameTime: 10d, + ExpectedVelocityAuthorityVersion: 0UL, + ShadowWorldOffsetX: request.CellId == SourceCell ? 0f : 192f, + ShadowWorldOffsetY: 0f); + + private static PhysicsSetPositionRequest CrossLandblockRequest() => + Request( + SourceCell, + new Vector3(193f, 12f, 7f), + new Vector3(193f, 12f, 7f)); + + private static PhysicsSetPositionRequest Request( + uint cellId, + Vector3 position, + Vector3? cellLocal = null) => new( + position, + Quaternion.Identity, + cellId, + cellLocal ?? position, + ImmutableArray.Empty, + Scale: 1f, + StepUpHeight: 0.4f, + StepDownHeight: 0.4f, + Flags: PhysicsSetPositionFlags.Placement + | PhysicsSetPositionFlags.Slide); + + private static RuntimeEntityRecord CreateRecord( + RuntimeEntityObjectLifetime lifetime, + uint guid, + ushort incarnation) + { + RuntimeEntityRecord record = lifetime.RegisterEntity( + Spawn(guid, incarnation)).Canonical!; + lifetime.Entities.SetFinalPhysicsState(record, PhysicsStateFlags.Gravity); + return record; + } + + private static PhysicsBody AttachBody( + RuntimeEntityObjectLifetime lifetime, + RuntimeEntityRecord record, + uint cellId, + PhysicsStateFlags state = PhysicsStateFlags.Gravity) + { + lifetime.Entities.SetFullCell( + record, + cellId, + (cellId & 0xFFFF0000u) | 0xFFFFu); + lifetime.Entities.SetFinalPhysicsState(record, state); + var body = new PhysicsBody + { + Position = new Vector3(10f, 20f, 7f), + Orientation = Quaternion.Identity, + LastUpdateTime = 1d, + State = state, + TransientState = TransientStateFlags.Active, + }; + body.SnapToCell(cellId, body.Position, body.Position); + lifetime.Entities.SetPhysicsBody(record, body); + record.ObjectClock.Activate(); + lifetime.Physics.AcknowledgeSpatialProjection(record, spatial: true); + return body; + } + + private static PhysicsEngine FlatEngine(uint landblock, float worldOffsetX) + { + var engine = new PhysicsEngine + { + DataCache = new PhysicsDataCache(), + }; + AddFlatLandblock(engine, landblock, worldOffsetX); + return engine; + } + + private static RuntimeLandblockCollisionAssets CollisionAssets( + uint landblockId) => new( + landblockId, + new TerrainSurface(new byte[81], new float[256]), + Array.Empty(), + Array.Empty(), + WorldOffsetX: 192f, + WorldOffsetY: 0f, + CurrentCellId: 0u); + + private static RuntimeCollisionGenerationCommit CommitPrepared( + RuntimePhysicsState physics, + RuntimeCollisionAdmission admission, + PreparedLandblockCollisionGeneration prepared) + { + while (true) + { + while (!physics.AdvanceCollisionRetainedOwnerCapture( + admission, + prepared).Completed) + { + } + foreach (uint ownerId in prepared.RetainedOwnerIds) + { + physics.RefreshCollisionRetainedOwner( + admission, + prepared, + ownerId); + } + RuntimeCollisionSealStep seal; + do + { + seal = physics.AdvanceCollisionGenerationSeal( + admission, + prepared); + } + while (!seal.Completed && !seal.Restarted); + if (seal.Completed) + break; + } + return physics.CommitCollisionGeneration(admission, prepared); + } + + private static void AddSyntheticCell( + PhysicsDataCache cache, + uint cellId) + { + cache.RegisterCellStructForTest( + cellId, + new CellPhysics + { + WorldTransform = Matrix4x4.Identity, + InverseWorldTransform = Matrix4x4.Identity, + Resolved = new Dictionary(), + Portals = [new PortalInfo(0, 0, 0)], + CellBSP = new CellBSPTree + { + Root = new CellBSPNode { Type = BSPNodeType.Leaf }, + }, + }); + cache.CellGraph.Add(new EnvCell( + cellId, + Matrix4x4.Identity, + Matrix4x4.Identity, + Vector3.Zero, + Vector3.One, + Array.Empty(), + Array.Empty(), + seenOutside: false, + containmentBsp: null)); + } + + private static void AddFlatLandblock( + PhysicsEngine engine, + uint landblock, + float worldOffsetX) + { + engine.AddLandblock( + landblock, + new TerrainSurface(new byte[81], new float[256]), + Array.Empty(), + Array.Empty(), + worldOffsetX, + worldOffsetY: 0f); + } + + private static WorldSession.EntitySpawn Spawn(uint guid, ushort instance) + { + var position = new CreateObject.ServerPosition( + SourceCell, + 10f, + 20f, + 7f, + 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: (uint)PhysicsStateFlags.Gravity, + 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, + "set-position-fixture", + null, + null, + 0x09000001u, + PhysicsState: (uint)PhysicsStateFlags.Gravity, + InstanceSequence: instance, + MovementSequence: 1, + ServerControlSequence: 1, + PositionSequence: 1, + Physics: physics); + } + + private sealed class PlacementObserver( + Action? onPlacement = null) + : IRuntimePlacementObserver + { + internal List Deltas { get; } = []; + + public void OnPlacement(in RuntimePlacementDelta delta) + { + Deltas.Add(delta); + onPlacement?.Invoke(delta); + } + } + + private sealed class EntityObserver : IRuntimeEntityObjectObserver + { + internal List Deltas { get; } = []; + + public void OnEntity(in RuntimeEntityDelta delta) => Deltas.Add(delta); + + public void OnInventory(in RuntimeInventoryDelta delta) + { + } + } + + private sealed class ThrowingPlacementObserver : IRuntimePlacementObserver + { + public void OnPlacement(in RuntimePlacementDelta delta) => + throw new InvalidOperationException("fixture observer failure"); + } + + private sealed class ReentrantRemotePlacement(PhysicsBody body) + : IRuntimeRemotePlacement + { + private Func? _readCell; + private Action? _writeCell; + private uint _cellId; + + public PhysicsBody Body { get; } = 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; } + internal Action? OnHitGround { get; init; } + internal Action? OnLeaveGround { get; init; } + + public void BindCanonicalCell(Func read, Action write) + { + _readCell = read; + _writeCell = write; + } + + public void HitGround() => OnHitGround?.Invoke(); + + public void LeaveGround() => OnLeaveGround?.Invoke(); + } + + private sealed class ManualTimeProvider(DateTimeOffset utcNow) + : TimeProvider + { + private DateTimeOffset _utcNow = utcNow; + private long _timestamp; + + public override DateTimeOffset GetUtcNow() => _utcNow; + public override long TimestampFrequency => TimeSpan.TicksPerSecond; + public override long GetTimestamp() => _timestamp; + + internal void Advance(TimeSpan duration) + { + _utcNow += duration; + _timestamp += duration.Ticks; + } + + internal void JumpUtc(TimeSpan duration) => _utcNow += duration; + } + + private enum CancellationChannel : uint + { + Position, + Pickup, + Parent, + } +} + +internal static class RuntimePlacementProjectionTokenTestExtensions +{ + internal static ulong TokenSequence( + this RuntimePlacementProjectionToken token) => token.Sequence; +}