feat(runtime): own deferred set-position residence
This commit is contained in:
parent
e84a388e6f
commit
4c02ac4259
18 changed files with 5557 additions and 34 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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<double>` 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.
|
||||
|
|
|
|||
|
|
@ -23,6 +23,9 @@
|
|||
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
|
||||
<_Parameter1>AcDream.Runtime</_Parameter1>
|
||||
</AssemblyAttribute>
|
||||
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
|
||||
<_Parameter1>AcDream.Runtime.Tests</_Parameter1>
|
||||
</AssemblyAttribute>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\AcDream.Plugin.Abstractions\AcDream.Plugin.Abstractions.csproj" />
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ internal sealed class CollisionWorldState
|
|||
internal Dictionary<uint, List<ShadowEntry>> ShadowCells { get; } = new();
|
||||
internal Dictionary<uint, List<uint>> ShadowEntityCells { get; } = new();
|
||||
internal HashSet<uint> SuspendedShadowEntities { get; } = new();
|
||||
internal Dictionary<uint, List<uint>> SuspendedShadowEntityCells { get; } = new();
|
||||
internal Dictionary<uint, HashSet<uint>> WithdrawnPrefixesByOwner { get; } = new();
|
||||
internal Dictionary<uint, IReadOnlyList<ShadowShape>> ShadowEntityShapes { get; } = new();
|
||||
internal Dictionary<uint, ShadowObjectRegistry.RegistrationRecord>
|
||||
|
|
|
|||
|
|
@ -33,6 +33,8 @@ public sealed class ShadowObjectRegistry
|
|||
_collisionWorld.Current.ShadowEntityCells; // for deregistration
|
||||
private HashSet<uint> _suspendedEntities =>
|
||||
_collisionWorld.Current.SuspendedShadowEntities;
|
||||
private Dictionary<uint, List<uint>> _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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Installs the shadow-list suffix of one canonical retail
|
||||
/// <c>CPhysicsObj::SetPositionInternal</c> 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.
|
||||
/// </summary>
|
||||
internal void CommitSetPosition(
|
||||
uint entityId,
|
||||
Vector3 worldPosition,
|
||||
Quaternion worldRotation,
|
||||
uint seedCellId,
|
||||
float worldOffsetX,
|
||||
float worldOffsetY,
|
||||
PhysicsShadowCommitAction action,
|
||||
System.Collections.Immutable.ImmutableArray<uint> 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<uint>? 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<uint> cellIds)
|
||||
{
|
||||
if (_entityToCells.TryGetValue(
|
||||
entityId,
|
||||
out List<uint>? previousCells))
|
||||
{
|
||||
for (int index = 0; index < previousCells.Count; index++)
|
||||
{
|
||||
if (_cells.TryGetValue(
|
||||
previousCells[index],
|
||||
out List<ShadowEntry>? entries))
|
||||
{
|
||||
RemoveOwnerRows(entries, entityId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_suspendedEntities.Remove(entityId);
|
||||
_suspendedEntityCells.Remove(entityId);
|
||||
_entityReg[entityId] = registration with
|
||||
{
|
||||
SeedCellId = seedCellId,
|
||||
EntityWorldPos = worldPosition,
|
||||
EntityWorldRot = worldRotation,
|
||||
};
|
||||
|
||||
var exactCells = new List<uint>(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<ShadowShape>? 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<uint>? withdrawn))
|
||||
{
|
||||
for (int index = 0; index < exactCells.Count; index++)
|
||||
withdrawn.Remove(exactCells[index] & 0xFFFF0000u);
|
||||
if (withdrawn.Count == 0)
|
||||
_withdrawnPrefixesByOwner.Remove(entityId);
|
||||
}
|
||||
BumpOwnerVersion(entityId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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<uint>(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<uint>? cells);
|
||||
_suspendedEntityCells.TryGetValue(
|
||||
entityId,
|
||||
out List<uint>? suspendedCells);
|
||||
_entityShapes.TryGetValue(
|
||||
entityId,
|
||||
out IReadOnlyList<ShadowShape>? shapes);
|
||||
|
|
@ -1394,6 +1615,7 @@ public sealed class ShadowObjectRegistry
|
|||
cells is null ? null : new List<uint>(cells),
|
||||
rows,
|
||||
_suspendedEntities.Contains(entityId),
|
||||
suspendedCells is null ? null : new List<uint>(suspendedCells),
|
||||
withdrawn is null ? null : new HashSet<uint>(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<uint>? CellIds,
|
||||
IReadOnlyList<PreparedShadowCellRows> Rows,
|
||||
bool Suspended,
|
||||
List<uint>? SuspendedCellIds,
|
||||
HashSet<uint>? 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();
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ public sealed class ParentAttachmentState
|
|||
private readonly Dictionary<uint, ParentAttachmentRelation> _stagedByChild = new();
|
||||
private readonly Dictionary<uint, ParentAttachmentRelation> _recoveryByChild = new();
|
||||
private readonly Dictionary<uint, ParentAttachmentRelation> _lastAcceptedByChild = new();
|
||||
private readonly Dictionary<ParentIncarnation, List<uint>> _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<uint>? 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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public IReadOnlyList<uint> ChildrenAttachedToParent(
|
||||
uint parentGuid,
|
||||
ushort parentInstanceSequence)
|
||||
{
|
||||
var parent = new ParentIncarnation(parentGuid, parentInstanceSequence);
|
||||
return _committedChildrenByParent.TryGetValue(
|
||||
parent,
|
||||
out List<uint>? children)
|
||||
? children
|
||||
: Array.Empty<uint>();
|
||||
}
|
||||
|
||||
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<uint> 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<uint>? 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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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> _pendingDispatch = [];
|
||||
private IRuntimeEntityObjectObserver[] _observers = [];
|
||||
private IRuntimePlacementObserver[] _placementObservers = [];
|
||||
private Func<RuntimeGenerationToken> _generation = static () => default;
|
||||
private Func<ulong> _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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -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<RuntimeEntityRecord>();
|
||||
|
||||
_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<bool> matchesCommittedMutation)
|
||||
Func<bool> matchesCommittedMutation,
|
||||
RuntimePlacementCancellationReceipt cancellation = default)
|
||||
{
|
||||
Physics.SetPosition.PublishCancellation(cancellation);
|
||||
if (!IsExpectedCanonical(canonical, matchesCommittedMutation))
|
||||
return false;
|
||||
try
|
||||
{
|
||||
acknowledgeProjection();
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -136,6 +137,8 @@ public sealed class RuntimeEntityRecord
|
|||
|
||||
internal void AdvanceMovementCommit() => MovementCommitVersion++;
|
||||
|
||||
internal void AdvancePlacementCommit() => PlacementCommitVersion++;
|
||||
|
||||
internal void AdvanceParentCommit() => ParentCommitVersion++;
|
||||
|
||||
internal void AdvanceObjDescAuthority() => ObjDescAuthorityVersion++;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<RuntimeEntityKey, RuntimeEntityRecord>
|
||||
_spatialRoots = new();
|
||||
private readonly Dictionary<RuntimeEntityKey, IRuntimeRemoteMotion>
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
2014
src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs
Normal file
2014
src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs
Normal file
File diff suppressed because it is too large
Load diff
167
tests/AcDream.Core.Tests/Physics/ShadowSetPositionCommitTests.cs
Normal file
167
tests/AcDream.Core.Tests/Physics/ShadowSetPositionCommitTests.cs
Normal file
|
|
@ -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<ShadowShape> 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);
|
||||
}
|
||||
|
|
@ -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<uint> 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,
|
||||
|
|
|
|||
2449
tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionStateTests.cs
Normal file
2449
tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionStateTests.cs
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue