feat(runtime): own deferred set-position residence

This commit is contained in:
Erik 2026-07-31 22:32:49 +02:00
parent e84a388e6f
commit 4c02ac4259
18 changed files with 5557 additions and 34 deletions

View file

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

View file

@ -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:273191273307 |
| ~~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:273001273090 |

View file

@ -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 (`0x005153E50x005154FE`). 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.

View file

@ -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" />

View file

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

View file

@ -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();

View file

@ -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(

View file

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

View file

@ -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. J4J6 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);
}
}

View file

@ -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();

View file

@ -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++;

View file

@ -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,

View file

@ -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,

View file

@ -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;

File diff suppressed because it is too large Load diff

View 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);
}

View file

@ -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,

File diff suppressed because it is too large Load diff