feat(runtime): own SetPosition collision reports
This commit is contained in:
parent
ec627c13a2
commit
237d1184d2
19 changed files with 3744 additions and 103 deletions
|
|
@ -241,6 +241,9 @@ src/
|
||||||
Physics/
|
Physics/
|
||||||
RuntimePhysicsState.cs -> per-session engine/cache/scratch/shadows,
|
RuntimePhysicsState.cs -> per-session engine/cache/scratch/shadows,
|
||||||
collision receipts, bodies/hosts/worksets
|
collision receipts, bodies/hosts/worksets
|
||||||
|
RuntimeCollisionReportingState.cs -> exact-key retail collision table,
|
||||||
|
environment latch, ordered callbacks, and
|
||||||
|
SetPosition report-result ownership
|
||||||
RuntimeSetPositionState.cs -> exact placement/lost-cell operations,
|
RuntimeSetPositionState.cs -> exact placement/lost-cell operations,
|
||||||
authored mover retention, ordered host
|
authored mover retention, ordered host
|
||||||
receipts, and collision-generation wake
|
receipts, and collision-generation wake
|
||||||
|
|
@ -336,9 +339,11 @@ src/
|
||||||
```
|
```
|
||||||
|
|
||||||
The 4B2 production SetPosition routes and shared local-controller body remain
|
The 4B2 production SetPosition routes and shared local-controller body remain
|
||||||
dormant until exact authored mover preparation, collision-report return,
|
dormant. Runtime now owns the exact collision table, environment latch, and
|
||||||
presentation-only rebucketing, placement-prefix quiescence, and an atomic
|
report-result semantics needed by that cutover. Activation still waits for
|
||||||
Runtime body/controller publication transaction land as one reviewed cutover.
|
exact authored mover preparation, presentation-only rebucketing,
|
||||||
|
placement-prefix quiescence, and an atomic Runtime body/controller publication
|
||||||
|
transaction to land as one reviewed cutover.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -107,7 +107,7 @@ readiness/requeue adaptation. See
|
||||||
| 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-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-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-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 | **NARROWED 2026-07-31 (placement Slice 4B2 checkpoint 1).** Runtime now owns exact lost-cell residence, adjusted frame retention, 25-second root/direct-child lifetime, generation-scoped wake, revisioned Withdraw/Place receipts, and one public generation-gated observe/retry/exact-ack seam. Shared local-controller body adoption remains deferred to the atomic all-route ownership cutover. Production authoritative placement still routes through the legacy recoverable outdoor demote and outdoor-restore `max(terrainZ, z)` lift until the remaining 4B2 prerequisites and routes cut over atomically. | `src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs`; `src/AcDream.Runtime/Physics/RuntimePlacementProjectionChannel.cs`; legacy route in `src/AcDream.Core/Physics/PhysicsEngine.cs` | The canonical owner remains dormant and separately gated, so this ownership checkpoint cannot partially change the accepted production world. | 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-1 | **NARROWED 2026-07-31 (placement Slice 4B2 checkpoint 2).** Runtime now owns exact lost-cell residence, adjusted frame retention, 25-second root/direct-child lifetime, generation-scoped wake, revisioned Withdraw/Place receipts, one public generation-gated observe/retry/exact-ack seam, and the retail collision-table/report-result state needed by SetPosition. Shared local-controller body adoption remains deferred to the atomic all-route ownership cutover. Production authoritative placement still routes through the legacy recoverable outdoor demote and outdoor-restore `max(terrainZ, z)` lift until the remaining authored-mover, rebucketing, prefix-quiescence, body-publication, and route-cutover prerequisites land atomically. | `src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs`; `src/AcDream.Runtime/Physics/RuntimeCollisionReportingState.cs`; `src/AcDream.Runtime/Physics/RuntimePlacementProjectionChannel.cs`; legacy route in `src/AcDream.Core/Physics/PhysicsEngine.cs` | The canonical owners remain dormant and separately gated, so this ownership checkpoint cannot partially change the accepted production world. | 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; `CPhysicsObj::handle_all_collisions` 0x00514780 |
|
||||||
| 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-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-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 |
|
| ~~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 |
|
||||||
|
|
@ -155,7 +155,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 |
|
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|
||||||
|---|---|---|---|---|---|
|
|---|---|---|---|---|---|
|
||||||
| AP-1 | **NARROWED 2026-07-31 (placement/streaming Slice 4B2 checkpoint 1).** Core exposes the pure retail `SetPosition` transaction; Runtime owns its exact accepted operation, complete canonical commit, deferred residence, lifetime, generation wake, and revisioned host receipts; and one public generation-gated channel exposes observe/retry/exact-head acknowledgement without another queue. Shared local-controller body adoption remains deferred to the atomic all-route ownership cutover. Production zero-delta routes deliberately remain on the legacy resolver until 4B2 supplies the real collision-report return, exact authored mover preparation, presentation-only rebucketing, placement-prefix quiescence, and the atomic graphical/headless route cutover. | `src/AcDream.Core/Physics/PhysicsSetPosition.cs`; `src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs`; `src/AcDream.Runtime/Physics/RuntimePlacementProjectionChannel.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 host seam land independently without partially changing production placement 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-1 | **NARROWED 2026-07-31 (placement/streaming Slice 4B2 checkpoint 2).** Core exposes the pure retail `SetPosition` transaction; Runtime owns its exact accepted operation, complete canonical commit, deferred residence, lifetime, generation wake, revisioned host receipts, and exact-key retail collision table/environment-latch/report-result state; and one public generation-gated channel exposes observe/retry/exact-head acknowledgement without another placement queue. Collision starts, expiry/force ends, static and `ReportAsEnvironment` routing, reciprocal eligibility, missile-state clearing, callback ordering, and failed-placement `Collided` versus `NoValidPosition` classification now share one presentation-free owner. Shared local-controller body adoption remains deferred to the atomic all-route ownership cutover. Production zero-delta routes deliberately remain on the legacy resolver until 4B2 supplies exact authored mover preparation, presentation-only rebucketing, placement-prefix quiescence, and the atomic graphical/headless route cutover. | `src/AcDream.Core/Physics/PhysicsSetPosition.cs`; `src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs`; `src/AcDream.Runtime/Physics/RuntimeCollisionReportingState.cs`; `src/AcDream.Runtime/Physics/RuntimePlacementProjectionChannel.cs`; `tests/AcDream.Core.Tests/Physics/PhysicsSetPositionTests.cs`; `tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionStateTests.cs`; `tests/AcDream.Runtime.Tests/Physics/RuntimeCollisionReportingStateTests.cs`; `docs/research/2026-07-31-canonical-set-position.md`; `docs/research/2026-07-31-runtime-set-position-collision-reporting.md` | The mechanism, ownership, report-result oracle, and host seam land independently without partially changing production placement 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 owners now existing. | `CPhysicsObj::SetPosition` 0x005160C0; `SetPositionInternal` 0x00515BD0; `CPhysicsObj::handle_all_collisions` 0x00514780; `track_object_collision` 0x00513F10; `report_collision_end` 0x00514620; `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-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 |
|
| ~~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 |
|
||||||
|
|
|
||||||
|
|
@ -60,12 +60,27 @@ automated gates pass and the connected buff/death gate was user-accepted on
|
||||||
2026-07-31. The final session also accepted burden/exhaustion, wall/corner,
|
2026-07-31. The final session also accepted burden/exhaustion, wall/corner,
|
||||||
crowd, two-client remote/door/portal, and shallow-water behavior. The user
|
crowd, two-client remote/door/portal, and shallow-water behavior. The user
|
||||||
waived the general sweep and explicitly deferred the barred-house gate as
|
waived the general sweep and explicitly deferred the barred-house gate as
|
||||||
#274. A separate tight-gap clearance mismatch is carried as #273 pending an
|
#274. The later exact-location #273 tight-gap gate is now fixed and accepted.
|
||||||
exact-location capture. World-interaction Slice 5 vendor browsing resumes.
|
|
||||||
|
**Remaining physics-divergence closeout (ACTIVE 2026-07-31):** the user then
|
||||||
|
authorized retirement of the remaining proven collision/placement gaps before
|
||||||
|
vendor work resumes. Nested retry, edge/StepDown/Path-6 ordering, exact cell
|
||||||
|
availability, atomic collision generations, canonical Core SetPosition,
|
||||||
|
Runtime lost-cell residence, and the dormant placement receipt channel are
|
||||||
|
landed. Placement Slice 4B2 checkpoint 2 adds the presentation-free Runtime
|
||||||
|
owner for retail collision tracking, environment latch, ordered callbacks,
|
||||||
|
missile-state clearing, expiry/force-end lifetime, and SetPosition's exact
|
||||||
|
report-result boolean. It deliberately does not activate an App or Headless
|
||||||
|
production route. Next are exact authored mover preparation, the atomic shared
|
||||||
|
body/controller transaction, presentation-only placement observers, collision-
|
||||||
|
prefix quiescence, and the all-route cutover which can retire AP-1/AD-1. AP-22
|
||||||
|
authored object shapes and AD-10 remote contact-plane projection follow, then
|
||||||
|
the final matrix and ledger closeout. Detailed handoff:
|
||||||
|
[`2026-07-31-runtime-set-position-collision-reporting-handoff.md`](../research/2026-07-31-runtime-set-position-collision-reporting-handoff.md).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Current program: world interaction completion (M4 prelude)
|
## Paused program: world interaction completion (M4 prelude)
|
||||||
|
|
||||||
The active work order is
|
The active work order is
|
||||||
[`2026-07-23-world-interaction-completion.md`](2026-07-23-world-interaction-completion.md).
|
[`2026-07-23-world-interaction-completion.md`](2026-07-23-world-interaction-completion.md).
|
||||||
|
|
|
||||||
|
|
@ -87,10 +87,18 @@ program: spell-bar overflow, status Use/Assess, assessment information,
|
||||||
equipped-child picking, vendor browsing, and authoritative vendor
|
equipped-child picking, vendor browsing, and authoritative vendor
|
||||||
transactions. This is deliberately using the extracted interaction owners and
|
transactions. This is deliberately using the extracted interaction owners and
|
||||||
canonical shared main-panel host before quest/emote/character-creation bodies
|
canonical shared main-panel host before quest/emote/character-creation bodies
|
||||||
broaden the feature surface. Slices 1–4 are user-accepted. Campaign P closed
|
broaden the feature surface. Slices 1–4 are user-accepted. Campaign P's
|
||||||
on 2026-07-31 with tight-gap collision clearance (#273) and the deferred
|
connected feel matrix closed on 2026-07-31 with tight-gap collision clearance
|
||||||
restricted-house gate (#274) explicitly carried; resume at Slice 5 vendor
|
(#273) and the deferred restricted-house gate (#274) explicitly carried. The
|
||||||
browsing.
|
user subsequently authorized the remaining physics-divergence closeout before
|
||||||
|
vendor work. Placement Slice 4B2 checkpoint 2 now gives Runtime the retail
|
||||||
|
SetPosition collision table, environment latch, ordered report callbacks, and
|
||||||
|
exact report-result owner without activating production placement. The
|
||||||
|
remaining order is authored mover/body preparation, atomic graphical/no-window
|
||||||
|
body publication, presentation-only projection and prefix quiescence, then the
|
||||||
|
all-route SetPosition cutover; AP-22 shape fidelity and AD-10 remote contact-
|
||||||
|
plane projection follow. Resume Slice 5 vendor browsing only after that
|
||||||
|
closeout or a new explicit user direction.
|
||||||
|
|
||||||
The separately authorized modern-runtime performance program has completed
|
The separately authorized modern-runtime performance program has completed
|
||||||
Slices A–D: corrected measurement, prepared-package bake/dedup, package-only
|
Slices A–D: corrected measurement, prepared-package bake/dedup, package-only
|
||||||
|
|
|
||||||
|
|
@ -252,15 +252,21 @@ prepared-mover caching, and caps synchronous Scatter/RandomScatter work at 64
|
||||||
attempts; this keeps valid authored retail request shapes while rejecting a
|
attempts; this keeps valid authored retail request shapes while rejecting a
|
||||||
hostile `uint.MaxValue` loop at the authority boundary.
|
hostile `uint.MaxValue` loop at the authority boundary.
|
||||||
|
|
||||||
Two boundaries intentionally remain open for 4B2:
|
One authority boundary intentionally remains open for 4B2:
|
||||||
|
|
||||||
- `RuntimePortalPlacementAuthority` validates immutable token shape only;
|
- `RuntimePortalPlacementAuthority` validates immutable token shape only;
|
||||||
4B2 must bind it to active `RuntimeWorldTransitState` generation, teleport
|
4B2 must bind it to active `RuntimeWorldTransitState` generation, teleport
|
||||||
sequence, destination, and host acknowledgement before reveal.
|
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 former collision-report boundary is closed by
|
||||||
the per-object report/tracking owner required for that boolean is not yet in
|
`RuntimeCollisionReportingState`. Runtime now owns retail's exact-key object
|
||||||
Runtime, and the former environment/object-presence guess is forbidden.
|
contact table, environment latch, strict ordinary/ethereal expiry, force-end,
|
||||||
|
static and `ReportAsEnvironment` routing, reciprocal callback eligibility,
|
||||||
|
missile-state clearing, ordered reentrant dispatch, and the report-result
|
||||||
|
boolean which distinguishes placement `Collided` from `NoValidPosition`.
|
||||||
|
Successful SetPosition commits reporting after Contact/OnWalkable and ground
|
||||||
|
callbacks but before its single physical response and shadow reflood. See
|
||||||
|
`docs/research/2026-07-31-runtime-set-position-collision-reporting.md`.
|
||||||
|
|
||||||
### Slice 4B2 checkpoint 1 — public dormant host seam
|
### Slice 4B2 checkpoint 1 — public dormant host seam
|
||||||
|
|
||||||
|
|
@ -284,13 +290,13 @@ preparation followed by one validated atomic body/controller publication.
|
||||||
Either choice belongs to the all-route ownership cutover, not this narrow
|
Either choice belongs to the all-route ownership cutover, not this narrow
|
||||||
dormant-seam checkpoint.
|
dormant-seam checkpoint.
|
||||||
|
|
||||||
This is still a deliberately non-activating checkpoint. Production spawn,
|
This remains a deliberately non-activating checkpoint. Production spawn,
|
||||||
Position, projectile, drop/pickup/parent, and portal routes do not submit to
|
Position, projectile, drop/pickup/parent, and portal routes do not submit to
|
||||||
the dormant SetPosition owner yet. The cutover remains blocked on the real
|
the dormant SetPosition owner yet. The cutover remains blocked on exact
|
||||||
retail collision report/tracking return, exact ordered Setup spheres/scale/
|
ordered Setup spheres/scale/step heights/flags/cell-local preparation,
|
||||||
step heights/flags/cell-local preparation, presentation-only rebucketing, and
|
presentation-only rebucketing, and placement-prefix quiescence before
|
||||||
placement-prefix quiescence before collision retirement. AP-1 and AD-1 remain
|
collision retirement. AP-1 and AD-1 remain open until those prerequisites and
|
||||||
open until those prerequisites and every production route land together.
|
every production route land together.
|
||||||
|
|
||||||
AD-2 remains the explicit async adaptation: collision readiness can publish in
|
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-
|
a different frame from retail's blocking load. A failed wake is safely re-
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,11 @@
|
||||||
# Remaining physics-divergence campaign handoff — 2026-07-31
|
# Remaining physics-divergence campaign handoff — 2026-07-31
|
||||||
|
|
||||||
|
> **Checkpoint 2 update:** Slice 4B2 prerequisite A, Runtime SetPosition
|
||||||
|
> collision-report ownership, is implemented in the next checkpoint. Continue
|
||||||
|
> with the dedicated
|
||||||
|
> [`runtime SetPosition collision-reporting handoff`](2026-07-31-runtime-set-position-collision-reporting-handoff.md),
|
||||||
|
> not the prerequisite-A instructions preserved below as historical context.
|
||||||
|
|
||||||
## Purpose and stopping point
|
## Purpose and stopping point
|
||||||
|
|
||||||
This is the deliberate handoff boundary requested after placement Slice 4B2
|
This is the deliberate handoff boundary requested after placement Slice 4B2
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,243 @@
|
||||||
|
# Runtime SetPosition collision-report ownership handoff - 2026-07-31
|
||||||
|
|
||||||
|
## Purpose and exact stopping point
|
||||||
|
|
||||||
|
This handoff records placement Slice 4B2 checkpoint 2: the isolated Runtime
|
||||||
|
owner for retail SetPosition collision tracking and report-result semantics.
|
||||||
|
The checkpoint intentionally stops before authored mover preparation, shared
|
||||||
|
local-controller body publication, graphical/headless placement projection,
|
||||||
|
collision-prefix quiescence, or any production SetPosition route cutover.
|
||||||
|
|
||||||
|
Production behavior is therefore unchanged by this checkpoint. The new owner
|
||||||
|
is populated only by the dormant `RuntimeSetPositionState` and focused tests.
|
||||||
|
AP-1 and AD-1 remain narrowed/open; AP-22 and AD-10 remain open.
|
||||||
|
|
||||||
|
## Exact workspace
|
||||||
|
|
||||||
|
- Worktree: `C:\Users\erikn\.codex\worktrees\af5e\acdream`
|
||||||
|
- Branch: `codex/port-claude-agents`
|
||||||
|
- Starting checkpoint: `ec627c13`
|
||||||
|
(`docs(physics): hand off remaining divergence campaign`)
|
||||||
|
- This handoff belongs to the same behavior commit as the implementation.
|
||||||
|
- No upstream is configured for this worktree branch.
|
||||||
|
- Remotes:
|
||||||
|
- `origin`: `https://git.snakedesert.se/erik/acdream.git`
|
||||||
|
- `github`: `git@github.com:eriknihlen/acdream.git`
|
||||||
|
|
||||||
|
Continue in this worktree unless the user explicitly requests otherwise.
|
||||||
|
`AGENTS.md` has an unrelated pre-existing content diff and must not be staged,
|
||||||
|
restored, or rewritten as part of this checkpoint. Several other paths report
|
||||||
|
line-ending/stat noise without a content diff; stage only the exact paths
|
||||||
|
listed in the final commit.
|
||||||
|
|
||||||
|
## Retail oracle
|
||||||
|
|
||||||
|
The complete readable oracle is
|
||||||
|
[`2026-07-31-runtime-set-position-collision-reporting.md`](2026-07-31-runtime-set-position-collision-reporting.md).
|
||||||
|
The named-retail anchors are:
|
||||||
|
|
||||||
|
- `CPhysicsObj::report_object_collision_end` `0x00510A90`
|
||||||
|
- `CPhysicsObj::report_environment_collision` `0x00512FC0`
|
||||||
|
- `CPhysicsObj::report_object_collision` `0x00513060`
|
||||||
|
- `CPhysicsObj::track_object_collision` `0x00513F10`
|
||||||
|
- `CPhysicsObj::report_collision_start` `0x00513FD0`
|
||||||
|
- `CPhysicsObj::report_collision_end` `0x00514620`
|
||||||
|
- `CPhysicsObj::handle_all_collisions` `0x00514780`
|
||||||
|
- successful `CPhysicsObj::SetPositionInternal(CTransition const*)`
|
||||||
|
`0x00515330`
|
||||||
|
- `CPhysicsObj::leave_world` `0x005155A0`
|
||||||
|
- placement failure in `CPhysicsObj::SetPositionInternal` `0x00515BD0`
|
||||||
|
|
||||||
|
The source is `docs/research/named-retail/acclient_2013_pseudo_c.txt`; the
|
||||||
|
struct authority is `docs/research/named-retail/acclient.h`.
|
||||||
|
|
||||||
|
## What this checkpoint implements
|
||||||
|
|
||||||
|
`RuntimeCollisionReportingState` is the sole per-session owner of:
|
||||||
|
|
||||||
|
- one environment-collision latch per exact `RuntimeEntityKey`;
|
||||||
|
- one ordered object-contact table per exact owner incarnation;
|
||||||
|
- retained peer server GUID, touch time, and ethereal-at-touch state;
|
||||||
|
- static and `ReportAsEnvironment` routing;
|
||||||
|
- asymmetric `IgnoreCollisions` and reciprocal `ReportCollisions` eligibility;
|
||||||
|
- strict ordinary `age > 1.0` and ethereal `age > 0.0` expiry;
|
||||||
|
- force-end-before-callback mutation for reentrant safety;
|
||||||
|
- missing-peer self-only end reports without resolving a later GUID reuse;
|
||||||
|
- exact `Missile | AlignPath | PathClipped` clearing on the canonical record,
|
||||||
|
borrowed body, retained shadow state, and mutation version;
|
||||||
|
- a monotonic immutable report FIFO with observer-failure isolation;
|
||||||
|
- the retail callback-eligibility boolean used by failed placement to choose
|
||||||
|
`Collided` versus `NoValidPosition`;
|
||||||
|
- terminal ownership diagnostics and deterministic session/disposal cleanup.
|
||||||
|
|
||||||
|
Successful dormant SetPosition commits contact, water/walkable and ground
|
||||||
|
edges first, runs reporting next, applies physical response once, and then
|
||||||
|
refloods the shadow. An intervening Vector or Movement update suppresses only
|
||||||
|
the stale physical response; it does not erase collision tracking or reports.
|
||||||
|
Failed placement always supplies retail's `previousContact = false` and
|
||||||
|
`previousOnWalkable = false`, reports once, applies its one response pass, and
|
||||||
|
maps the report result exactly.
|
||||||
|
|
||||||
|
Hidden, teleport/withdrawal, deletion, session reset, and disposal use distinct
|
||||||
|
lifetime edges. Leaving the world force-ends the departing owner's table but
|
||||||
|
retains its environment latch and incoming peer records. Destruction then
|
||||||
|
forgets only the departing owner state. Other owners retain exact-key contacts
|
||||||
|
until their own expiry/force pass and can emit a missing-target end using the
|
||||||
|
preserved server GUID. Hidden and session-clear paths force-end while the old
|
||||||
|
report flags and bodies are still eligible, before state/reset teardown.
|
||||||
|
|
||||||
|
## Architectural boundaries
|
||||||
|
|
||||||
|
- Runtime owns all canonical collision-report state and report-result logic.
|
||||||
|
- Core exposes only the exact successful SetPosition ordering seam and the
|
||||||
|
retained-shadow collision identity required by Runtime.
|
||||||
|
- App and Headless gain no report table, queue, heuristic, or production
|
||||||
|
placement consumer.
|
||||||
|
- Reports are presentation-free and keyed by exact Runtime identity.
|
||||||
|
- Network/update callbacks may re-enter, but every later mutation revalidates
|
||||||
|
current identity, body, and the relevant authority version.
|
||||||
|
- Physical-response velocity authority is deliberately separate from report
|
||||||
|
authority, matching retail's ordering without overwriting a newer vector.
|
||||||
|
|
||||||
|
## Validation and independent review
|
||||||
|
|
||||||
|
The saved final diff passed:
|
||||||
|
|
||||||
|
- combined focused Runtime collision-report and SetPosition tests: 76/76;
|
||||||
|
- complete Runtime project: 562/562;
|
||||||
|
- graphical/headless Runtime-physics ownership and dormancy guards: 4/4;
|
||||||
|
- focused Core SetPosition/contact/response ordering tests: 29/29;
|
||||||
|
- complete Core project: 4,224 passed / 1 intentional skip;
|
||||||
|
- from-source Release solution rebuild: 0 errors and 21 pre-existing test-
|
||||||
|
project nullable/analyzer warnings; this checkpoint introduces none;
|
||||||
|
- complete Release solution: 10,309 passed / 4 intentional skips;
|
||||||
|
- warmed steady-contact refresh: 0 managed bytes across 10,000 calls;
|
||||||
|
- warmed immediate dormant SetPosition commit: still below the existing
|
||||||
|
2,048-byte-per-operation ceiling, with no new captured-delegate cost;
|
||||||
|
- architecture/adversarial re-review: clean after fixing Hidden/session/delete
|
||||||
|
reentrancy, stale shadow-state authority, allocation churn, and batch cost;
|
||||||
|
- retail-conformance re-review: clean against every named address above.
|
||||||
|
|
||||||
|
The final retail re-review found and closed two last ordering defects before
|
||||||
|
sign-off: object collision now snapshots the mover's Missile bit before the
|
||||||
|
source callback and, when that snapshot was set, unconditionally masks the
|
||||||
|
current `Missile | AlignPath | PathClipped` bits afterward. Thus an ordinary
|
||||||
|
callback-added Missile is retained when the mover was not previously a missile,
|
||||||
|
but a callback which clears Missile and re-adds path bits cannot evade the
|
||||||
|
pre-gated retail mask. Environment collision retains retail's post-callback
|
||||||
|
current-Missile test. Successful SetPosition now
|
||||||
|
publishes reports before installing the new stationary-fall counter, applies
|
||||||
|
the physical response next, installs StationaryFall/Stop/Stuck transient bits
|
||||||
|
after response, and only then refloods the shadow.
|
||||||
|
|
||||||
|
The host guard reads both production source trees. It proves App and Headless
|
||||||
|
borrow `GameRuntime.EntityObjects.Physics`, declare no second collision table
|
||||||
|
or return heuristic, and still contain no placement-channel consumer. No
|
||||||
|
connected/live gate is required for this dormant checkpoint because no
|
||||||
|
production route can populate or publish the new report owner.
|
||||||
|
|
||||||
|
## Exact implementation and test paths
|
||||||
|
|
||||||
|
The behavior commit containing this handoff changes exactly these ten code and
|
||||||
|
test paths:
|
||||||
|
|
||||||
|
- `src/AcDream.Core/Physics/PhysicsObjUpdate.cs`
|
||||||
|
- `src/AcDream.Core/Physics/ShadowObjectRegistry.cs`
|
||||||
|
- `src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs`
|
||||||
|
- `src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs`
|
||||||
|
- `src/AcDream.Runtime/Entities/RuntimeEntityRecord.cs`
|
||||||
|
- `src/AcDream.Runtime/Physics/RuntimeCollisionReportingState.cs`
|
||||||
|
- `src/AcDream.Runtime/Physics/RuntimePhysicsState.cs`
|
||||||
|
- `src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs`
|
||||||
|
- `tests/AcDream.Runtime.Tests/Physics/RuntimeCollisionReportingStateTests.cs`
|
||||||
|
- `tests/AcDream.App.Tests/Runtime/RuntimePhysicsOwnershipTests.cs`
|
||||||
|
|
||||||
|
The same commit synchronizes the architecture, divergence register, canonical
|
||||||
|
SetPosition research, roadmap, milestones, project memory, prior campaign
|
||||||
|
handoff pointer, retail-oracle note, and this detailed handoff. `AGENTS.md` and
|
||||||
|
the pre-existing line-ending/stat-noise paths are deliberately excluded.
|
||||||
|
|
||||||
|
## Remaining work - required order
|
||||||
|
|
||||||
|
### 1. Exact authored mover preparation
|
||||||
|
|
||||||
|
Build every SetPosition request from Setup's ordered authored spheres, exact
|
||||||
|
scale/presence semantics, StepUp/StepDown heights, flags, cell-local frame and
|
||||||
|
orientation, and current position/vector/state authority versions. Do not
|
||||||
|
synthesize a cylinder from visual radius/height or pre-mutate canonical state.
|
||||||
|
|
||||||
|
### 2. Atomic local-controller/body publication
|
||||||
|
|
||||||
|
Prepare off-canonical, then perform one Runtime-validated atomic transaction
|
||||||
|
which publishes the exact same body to graphical and no-window controllers.
|
||||||
|
Every body writer, remote/projectile binding, SetPosition operation, clock
|
||||||
|
epoch, deletion, reset and disposal path must participate. Do not resurrect
|
||||||
|
the rejected snapshot/rollback lease documented in the prior handoff.
|
||||||
|
|
||||||
|
### 3. Presentation-only host projection
|
||||||
|
|
||||||
|
Implement graphical and headless observers over the existing dormant placement
|
||||||
|
receipt channel. Withdraw removes presentation/spatial consumers while
|
||||||
|
retaining Runtime identity; Place projects only the immutable committed frame;
|
||||||
|
Discard retires the older revision. Host failure retries the exact FIFO head
|
||||||
|
and never rolls Runtime back.
|
||||||
|
|
||||||
|
### 4. Collision-prefix quiescence and atomic route activation
|
||||||
|
|
||||||
|
Park SetPosition residents before retiring their collision prefix, publish the
|
||||||
|
complete replacement generation, wake exact matching residents, and cut every
|
||||||
|
spawn/Position/portal/projectile/drop/pickup/parent/delete route over together.
|
||||||
|
Only then may AP-1 and AD-1 retire.
|
||||||
|
|
||||||
|
### 5. Remaining campaign slices
|
||||||
|
|
||||||
|
- Port retail-authored object collision shape precedence and retire AP-22.
|
||||||
|
- Remove remote terrain-normal preprojection and let the transition resolver
|
||||||
|
use the retained contact plane, retiring AD-10.
|
||||||
|
- Run the full automated and connected matrix, update all ledgers, and close
|
||||||
|
the remaining physics campaign only with direct evidence.
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
This checkpoint is one bisectable commit. Revert the commit containing this
|
||||||
|
file to remove collision-report ownership without disturbing the earlier
|
||||||
|
SetPosition residence and receipt-channel checkpoints. Do not revive the old
|
||||||
|
collision-presence guess or the rejected body snapshot lease.
|
||||||
|
|
||||||
|
Because a Git commit cannot embed its own final hash, resolve the exact
|
||||||
|
checkpoint and revert command without ambiguity using:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$checkpoint = git log -1 --format=%H -- `
|
||||||
|
docs/research/2026-07-31-runtime-set-position-collision-reporting-handoff.md
|
||||||
|
git show --stat $checkpoint
|
||||||
|
git revert $checkpoint
|
||||||
|
```
|
||||||
|
|
||||||
|
Earlier rollback points remain:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
git revert 270f5154 # dormant public placement receipt channel
|
||||||
|
git revert 4c02ac42 # Runtime SetPosition/lost-cell residence owner
|
||||||
|
git revert e84a388e # pure Core retail SetPosition transaction
|
||||||
|
```
|
||||||
|
|
||||||
|
## Resume procedure
|
||||||
|
|
||||||
|
1. Continue in
|
||||||
|
`C:\Users\erikn\.codex\worktrees\af5e\acdream` and verify
|
||||||
|
`git branch --show-current` reports `codex/port-claude-agents`.
|
||||||
|
2. Resolve the exact checkpoint with the `git log` command above and confirm
|
||||||
|
it is the current `HEAD` before starting the next behavior slice.
|
||||||
|
3. Read `AGENTS.md`, `docs/architecture/acdream-architecture.md`, this file,
|
||||||
|
the collision-report oracle, the canonical SetPosition research, and the
|
||||||
|
prior remaining-campaign handoff completely.
|
||||||
|
4. Run `git status --short`. Preserve the unrelated `AGENTS.md` content diff
|
||||||
|
and every documented line-ending/stat-noise path. Never stage by blanket.
|
||||||
|
5. Begin only **Exact authored mover preparation**, the first remaining item
|
||||||
|
above. Do not activate production routes, retire AP-1/AD-1, begin AP-22 or
|
||||||
|
AD-10, or resurrect the rejected body snapshot/rollback lease.
|
||||||
|
6. Use exact-path staging and rerun the matching focused projects,
|
||||||
|
`dotnet build AcDream.slnx -c Release --nologo`, and
|
||||||
|
`dotnet test AcDream.slnx -c Release --no-build --nologo` before the next
|
||||||
|
reviewed checkpoint.
|
||||||
|
|
@ -0,0 +1,182 @@
|
||||||
|
# Runtime SetPosition collision-report ownership
|
||||||
|
|
||||||
|
**Scope:** placement/streaming Slice 4B2 prerequisite A only. This closes the
|
||||||
|
missing Runtime owner for retail collision tracking and the boolean returned by
|
||||||
|
`CPhysicsObj::handle_all_collisions`. It does **not** activate any graphical or
|
||||||
|
headless production SetPosition route.
|
||||||
|
|
||||||
|
## Named-retail oracle
|
||||||
|
|
||||||
|
Primary sources:
|
||||||
|
|
||||||
|
- `CPhysicsObj::report_object_collision_end` `0x00510A90`
|
||||||
|
- `CPhysicsObj::report_environment_collision` `0x00512FC0`
|
||||||
|
- `CPhysicsObj::report_object_collision` `0x00513060`
|
||||||
|
- `CPhysicsObj::track_object_collision` `0x00513F10`
|
||||||
|
- `CPhysicsObj::report_collision_start` `0x00513FD0`
|
||||||
|
- `CPhysicsObj::report_collision_end` `0x00514620`
|
||||||
|
- `CPhysicsObj::handle_all_collisions` `0x00514780`
|
||||||
|
- `CPhysicsObj::SetPositionInternal(CTransition const*)` `0x00515330`
|
||||||
|
- `CPhysicsObj::leave_world` `0x005155A0`
|
||||||
|
- placement failure path in `CPhysicsObj::SetPositionInternal` `0x00515BD0`
|
||||||
|
- `CPhysicsObj::CollisionRecord`, `EnvCollisionProfile`,
|
||||||
|
`ObjCollisionProfile`, and `AtkCollisionProfile` in
|
||||||
|
`docs/research/named-retail/acclient.h`
|
||||||
|
|
||||||
|
The source text is
|
||||||
|
`docs/research/named-retail/acclient_2013_pseudo_c.txt`. The addresses above
|
||||||
|
are the behavioral authority; the older unnamed chunks remain fallback only.
|
||||||
|
|
||||||
|
### Environment reporting
|
||||||
|
|
||||||
|
```text
|
||||||
|
report_environment_collision(meInContact):
|
||||||
|
reported = false
|
||||||
|
if !colliding_with_environment:
|
||||||
|
if self.ReportCollisions && self.weenie != null:
|
||||||
|
DoCollision(EnvCollisionProfile(self.velocity, meInContact))
|
||||||
|
reported = true
|
||||||
|
colliding_with_environment = true
|
||||||
|
if self.Missile:
|
||||||
|
self.state &= ~(Missile | AlignPath | PathClipped)
|
||||||
|
return reported
|
||||||
|
```
|
||||||
|
|
||||||
|
The latch is independent of callback eligibility. An object with no collision
|
||||||
|
callback still latches its environment contact, and a repeated environment hit
|
||||||
|
returns false. Retail has no environment-end callback. `leave_world` does not
|
||||||
|
clear this latch; the next `handle_all_collisions` call re-arms it only after a
|
||||||
|
non-environment frame.
|
||||||
|
|
||||||
|
### Object reporting and tracking
|
||||||
|
|
||||||
|
```text
|
||||||
|
track_object_collision(other, meInContact):
|
||||||
|
if other.Static:
|
||||||
|
return report_environment_collision(meInContact)
|
||||||
|
|
||||||
|
record = { touched_time = PhysicsTimer.curr_time,
|
||||||
|
ethereal = other.Ethereal }
|
||||||
|
existed = collision_table.clobber(other.id, record)
|
||||||
|
if existed:
|
||||||
|
return false
|
||||||
|
return report_object_collision(other, meInContact)
|
||||||
|
```
|
||||||
|
|
||||||
|
The table insert/refresh precedes callbacks. Duplicate contacts refresh their
|
||||||
|
time but never replay a start callback. DAT/static classification and physics
|
||||||
|
state come from the exact shadow object which produced the collision; object-ID
|
||||||
|
presence is not a valid substitute.
|
||||||
|
|
||||||
|
`report_object_collision` first maps `ReportAsEnvironment` to the environment
|
||||||
|
path. Otherwise:
|
||||||
|
|
||||||
|
- the mover reports only when the other object is not `IgnoreCollisions` and
|
||||||
|
the mover has `ReportCollisions` plus a weenie;
|
||||||
|
- a mover which had Missile set before the source callback unconditionally
|
||||||
|
masks its current `Missile | AlignPath | PathClipped` bits after striking a
|
||||||
|
non-ignored object, even when the callback cleared Missile but re-added path
|
||||||
|
bits; when pre-callback Missile was clear, callback-added Missile is retained;
|
||||||
|
- the reciprocal report occurs only when the other has `ReportCollisions`, the
|
||||||
|
mover is not `IgnoreCollisions`, and the other has a weenie;
|
||||||
|
- the return is true when at least one callback is attempted. It is never a
|
||||||
|
collision-presence boolean.
|
||||||
|
|
||||||
|
### Expiry and end reporting
|
||||||
|
|
||||||
|
`report_collision_end(force)` removes records before dispatching callbacks.
|
||||||
|
This ordering is required for safe reentrancy.
|
||||||
|
|
||||||
|
```text
|
||||||
|
ordinary record: remove when age > 1.0, or force
|
||||||
|
ethereal record: remove when age > 0.0, or force
|
||||||
|
```
|
||||||
|
|
||||||
|
Equality remains alive. A still-resolvable non-`ReportAsEnvironment` peer may
|
||||||
|
receive reciprocal collision-end callbacks. When the peer no longer resolves,
|
||||||
|
the owner can still receive its self-only end using the stored retail object
|
||||||
|
ID. A later incarnation must never satisfy the old contact record.
|
||||||
|
|
||||||
|
### `handle_all_collisions` and SetPosition ordering
|
||||||
|
|
||||||
|
```text
|
||||||
|
handle_all_collisions(info, previousContact, previousOnWalkable):
|
||||||
|
reported = false
|
||||||
|
for other in info.collidedObjects, in encounter order:
|
||||||
|
reported |= track_object_collision(other, previousContact)
|
||||||
|
report_collision_end(force = false)
|
||||||
|
|
||||||
|
if environment latch is already set:
|
||||||
|
latch = info.collided_with_environment
|
||||||
|
else if info.collided_with_environment
|
||||||
|
|| (!previousOnWalkable && self.OnWalkable):
|
||||||
|
reported |= report_environment_collision(previousContact)
|
||||||
|
|
||||||
|
apply retail collision velocity/stationary response
|
||||||
|
return reported
|
||||||
|
```
|
||||||
|
|
||||||
|
Successful `SetPositionInternal(CTransition const*)` commits the resolved
|
||||||
|
cell/frame, Contact/WaterContact/OnWalkable state, and HitGround/LeaveGround
|
||||||
|
edge before `handle_all_collisions`; it ignores the returned boolean and only
|
||||||
|
then replaces/refloods shadows. Collision reports observe the old stationary-
|
||||||
|
fall state; the new counter is installed before physical response, while the
|
||||||
|
StationaryFall/Stop/Stuck transient bits are replaced after response and before
|
||||||
|
shadow reflood. The placement failure path calls
|
||||||
|
`handle_all_collisions(info, false, false)` and maps true to
|
||||||
|
`SetPositionError::Collided` (`4`) and false to `NoValidPosition` (`2`).
|
||||||
|
|
||||||
|
Consequently acdream must keep report/tracking separate from the physical
|
||||||
|
response: failed placement runs both once, while successful Runtime commit
|
||||||
|
runs reporting between the contact/ground commit and shadow reflood without
|
||||||
|
double-applying velocity response.
|
||||||
|
|
||||||
|
## Runtime ownership contract
|
||||||
|
|
||||||
|
The implementation is presentation-free and belongs to the per-session
|
||||||
|
`RuntimePhysicsState` graph. Its invariants are:
|
||||||
|
|
||||||
|
- owner and peer identities are exact `RuntimeEntityKey` values, not server
|
||||||
|
GUID or local ID alone;
|
||||||
|
- each tracked record retains the peer server GUID, touch time in the Runtime
|
||||||
|
simulation-clock domain, and ethereal-at-touch bit;
|
||||||
|
- collided IDs and authored/static ownership are admitted through the exact
|
||||||
|
retained `ShadowObjectRegistry` registration which produced the collision;
|
||||||
|
every dynamic Static/Ethereal/Ignore/ReportAsEnvironment decision then reads
|
||||||
|
the current canonical `PhysicsBody.State`, never a stale shadow snapshot;
|
||||||
|
- immutable reports preserve encounter order and dispatch through a retained,
|
||||||
|
reentrancy-safe FIFO;
|
||||||
|
- callback exceptions are isolated, while the retail report-result boolean is
|
||||||
|
determined by callback eligibility and does not depend on subscribers;
|
||||||
|
- every callback boundary revalidates the exact record/body/authority before
|
||||||
|
any later canonical mutation;
|
||||||
|
- force-end mutates the complete expired set before publishing ends; exact-key
|
||||||
|
admission guards prevent callback reentry from recreating a leaving owner,
|
||||||
|
and session teardown blocks the whole owner batch before its first callback;
|
||||||
|
- one source lifetime token covers a complete precollected end batch, so a
|
||||||
|
callback-accepted delete stops every later peer report even while teardown
|
||||||
|
sidecars remain resolvable;
|
||||||
|
- lifetime forget, session reset, and disposal cannot donate state to GUID
|
||||||
|
reuse;
|
||||||
|
- terminal ownership diagnostics include contact/report state and converge to
|
||||||
|
zero;
|
||||||
|
- graphical and no-window hosts borrow the same Runtime owner. No host owns a
|
||||||
|
second collision table or report-result heuristic.
|
||||||
|
|
||||||
|
The warmed steady-contact refresh path allocates zero managed bytes. Expired
|
||||||
|
contact storage is allocated lazily only after the first actual expiry, and
|
||||||
|
session-batch teardown is linear in owner count.
|
||||||
|
|
||||||
|
## Deliberately deferred
|
||||||
|
|
||||||
|
The canonical SetPosition owner remains dormant in production. The following
|
||||||
|
belong to later 4B2 commits and are not part of this checkpoint:
|
||||||
|
|
||||||
|
- exact ordered Setup spheres, authored scale and step-height preparation;
|
||||||
|
- the atomic shared local-controller body transaction;
|
||||||
|
- presentation-only rebucketing and placement-prefix quiescence;
|
||||||
|
- graphical/headless spawn, Position, portal, projectile, drop, pickup,
|
||||||
|
parent, and delete route cutover.
|
||||||
|
|
||||||
|
AP-1 and AD-1 therefore remain open, narrowed only by removal of the
|
||||||
|
collision-report prerequisite.
|
||||||
|
|
@ -1,5 +1,25 @@
|
||||||
# Collision System Port - Status and Plan
|
# Collision System Port - Status and Plan
|
||||||
|
|
||||||
|
## 2026-07-31 placement checkpoint 2
|
||||||
|
|
||||||
|
Runtime now owns the retail collision-report state required by canonical
|
||||||
|
SetPosition: exact-incarnation object-contact tables, the environment latch,
|
||||||
|
ordinary/ethereal expiry, force-end ordering, asymmetric object/environment
|
||||||
|
report eligibility, missile-state clearing, reentrant ordered reports, and the
|
||||||
|
report-result boolean which distinguishes failed-placement `Collided` from
|
||||||
|
`NoValidPosition`. Successful dormant SetPosition commits reporting after
|
||||||
|
contact/ground state and before its one response plus shadow reflood. The
|
||||||
|
named-retail oracle and next-agent instructions are in:
|
||||||
|
|
||||||
|
- `docs/research/2026-07-31-runtime-set-position-collision-reporting.md`
|
||||||
|
- `docs/research/2026-07-31-runtime-set-position-collision-reporting-handoff.md`
|
||||||
|
|
||||||
|
This checkpoint deliberately does not activate production placement. Next,
|
||||||
|
4B2 must prepare exact authored movers and land one atomic Runtime body/local-
|
||||||
|
controller transaction, followed by presentation-only host projection,
|
||||||
|
collision-prefix quiescence, and an all-route cutover. AP-1 and AD-1 remain
|
||||||
|
open until that cutover; AP-22 and AD-10 remain later campaign slices.
|
||||||
|
|
||||||
## Current State (2026-04-29)
|
## Current State (2026-04-29)
|
||||||
|
|
||||||
The collision system is no longer a pure placeholder and should not be treated
|
The collision system is no longer a pure placeholder and should not be treated
|
||||||
|
|
|
||||||
|
|
@ -81,14 +81,53 @@ public static class PhysicsObjUpdate
|
||||||
Action? leaveGround = null,
|
Action? leaveGround = null,
|
||||||
Func<bool>? isCurrent = null,
|
Func<bool>? isCurrent = null,
|
||||||
Func<bool>? isVelocityCurrent = null)
|
Func<bool>? isVelocityCurrent = null)
|
||||||
|
{
|
||||||
|
if (!CommitSetPositionContactTransition(
|
||||||
|
body,
|
||||||
|
inContact,
|
||||||
|
onWalkable,
|
||||||
|
previousOnWalkable,
|
||||||
|
hitGround,
|
||||||
|
leaveGround,
|
||||||
|
isCurrent))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Position, Vector, and Movement are independently timestamped but
|
||||||
|
// can all install m_velocityVector. If a later one arrived from a
|
||||||
|
// callback above, retain its vector and finish the non-overlapping
|
||||||
|
// contact/pose commit without applying this older collision response.
|
||||||
|
if (isVelocityCurrent?.Invoke() == false)
|
||||||
|
return isCurrent?.Invoke() ?? true;
|
||||||
|
|
||||||
|
HandleAllCollisions(
|
||||||
|
body,
|
||||||
|
collisionNormalValid,
|
||||||
|
collisionNormal,
|
||||||
|
previousContact,
|
||||||
|
previousOnWalkable,
|
||||||
|
body.OnWalkable);
|
||||||
|
return isCurrent?.Invoke() ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Commits retail's Contact/OnWalkable and HitGround/LeaveGround prefix,
|
||||||
|
/// stopping immediately before <c>handle_all_collisions</c>. Runtime uses
|
||||||
|
/// this seam to run the canonical collision-table reports without adding
|
||||||
|
/// another per-placement delegate allocation.
|
||||||
|
/// </summary>
|
||||||
|
public static bool CommitSetPositionContactTransition(
|
||||||
|
PhysicsBody body,
|
||||||
|
bool inContact,
|
||||||
|
bool onWalkable,
|
||||||
|
bool previousOnWalkable,
|
||||||
|
Action? hitGround = null,
|
||||||
|
Action? leaveGround = null,
|
||||||
|
Func<bool>? isCurrent = null)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(body);
|
ArgumentNullException.ThrowIfNull(body);
|
||||||
|
|
||||||
// SetPositionInternal replaces Contact first but retains the source
|
|
||||||
// OnWalkable bit through its first calc_acceleration call. A deferred
|
|
||||||
// teleport may have parked the live body with both bits cleared, so
|
|
||||||
// restore the captured source bit explicitly before reproducing that
|
|
||||||
// ordering.
|
|
||||||
if (previousOnWalkable)
|
if (previousOnWalkable)
|
||||||
body.TransientState |= TransientStateFlags.OnWalkable;
|
body.TransientState |= TransientStateFlags.OnWalkable;
|
||||||
else
|
else
|
||||||
|
|
@ -106,10 +145,6 @@ public static class PhysicsObjUpdate
|
||||||
else
|
else
|
||||||
body.TransientState &= ~TransientStateFlags.OnWalkable;
|
body.TransientState &= ~TransientStateFlags.OnWalkable;
|
||||||
|
|
||||||
// AP-10 (Campaign P Slice P4, 2026-07-30): mirror WATER_CONTACT_TS
|
|
||||||
// alongside CONTACT_TS/ON_WALKABLE_TS, same as ApplySetPositionContact.
|
|
||||||
// Callers (e.g. RemoteTeleportPlacement) already set body.ContactPlaneIsWater
|
|
||||||
// before invoking this commit.
|
|
||||||
if (body.ContactPlaneIsWater)
|
if (body.ContactPlaneIsWater)
|
||||||
body.TransientState |= TransientStateFlags.WaterContact;
|
body.TransientState |= TransientStateFlags.WaterContact;
|
||||||
else
|
else
|
||||||
|
|
@ -128,21 +163,6 @@ public static class PhysicsObjUpdate
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
body.calc_acceleration();
|
body.calc_acceleration();
|
||||||
|
|
||||||
// Position, Vector, and Movement are independently timestamped but
|
|
||||||
// can all install m_velocityVector. If a later one arrived from a
|
|
||||||
// callback above, retain its vector and finish the non-overlapping
|
|
||||||
// contact/pose commit without applying this older collision response.
|
|
||||||
if (isVelocityCurrent?.Invoke() == false)
|
|
||||||
return isCurrent?.Invoke() ?? true;
|
|
||||||
|
|
||||||
HandleAllCollisions(
|
|
||||||
body,
|
|
||||||
collisionNormalValid,
|
|
||||||
collisionNormal,
|
|
||||||
previousContact,
|
|
||||||
previousOnWalkable,
|
|
||||||
finalOnWalkable);
|
|
||||||
return isCurrent?.Invoke() ?? true;
|
return isCurrent?.Invoke() ?? true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -179,14 +199,14 @@ public static class PhysicsObjUpdate
|
||||||
// is now owned by the SetPositionInternal-derived contact flags, not a Velocity.Z<=0
|
// is now owned by the SetPositionInternal-derived contact flags, not a Velocity.Z<=0
|
||||||
// gate). A grounded corridor wall-slide keeps its tangential velocity (should_reflect
|
// gate). A grounded corridor wall-slide keeps its tangential velocity (should_reflect
|
||||||
// false), exactly as retail.
|
// false), exactly as retail.
|
||||||
bool sledding = body.State.HasFlag(PhysicsStateFlags.Sledding);
|
bool sledding = (body.State & PhysicsStateFlags.Sledding) != 0;
|
||||||
bool shouldReflect = !(prevOnWalkable && nowOnWalkable && !sledding);
|
bool shouldReflect = !(prevOnWalkable && nowOnWalkable && !sledding);
|
||||||
|
|
||||||
if (body.FramesStationaryFall <= 1)
|
if (body.FramesStationaryFall <= 1)
|
||||||
{
|
{
|
||||||
if (shouldReflect && collisionNormalValid)
|
if (shouldReflect && collisionNormalValid)
|
||||||
{
|
{
|
||||||
if (body.State.HasFlag(PhysicsStateFlags.Inelastic))
|
if ((body.State & PhysicsStateFlags.Inelastic) != 0)
|
||||||
{
|
{
|
||||||
body.Velocity = Vector3.Zero; // pc:282720-282722
|
body.Velocity = Vector3.Zero; // pc:282720-282722
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1563,6 +1563,32 @@ public sealed class ShadowObjectRegistry
|
||||||
internal bool HasLogicalOwner(uint entityId) =>
|
internal bool HasLogicalOwner(uint entityId) =>
|
||||||
_entityReg.ContainsKey(entityId);
|
_entityReg.ContainsKey(entityId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the exact collision identity consumed by retail
|
||||||
|
/// <c>CPhysicsObj::track_object_collision</c>. Reporting must classify
|
||||||
|
/// an encountered object from the same retained shadow registration that
|
||||||
|
/// produced the collision; presence of an entity id alone is not enough
|
||||||
|
/// to infer either a static/environment collision or physics state.
|
||||||
|
/// </summary>
|
||||||
|
internal bool TryGetCollisionOwner(
|
||||||
|
uint entityId,
|
||||||
|
out uint physicsState,
|
||||||
|
out bool isStatic)
|
||||||
|
{
|
||||||
|
if (_entityReg.TryGetValue(
|
||||||
|
entityId,
|
||||||
|
out RegistrationRecord? registration))
|
||||||
|
{
|
||||||
|
physicsState = registration.State;
|
||||||
|
isStatic = registration.IsStatic;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
physicsState = 0u;
|
||||||
|
isStatic = false;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
public int PrefixOwnerSlotCapacityForDiagnostics(uint landblockId) =>
|
public int PrefixOwnerSlotCapacityForDiagnostics(uint landblockId) =>
|
||||||
_prefixOwnerSlots.TryGetValue(
|
_prefixOwnerSlots.TryGetValue(
|
||||||
landblockId & 0xFFFF0000u,
|
landblockId & 0xFFFF0000u,
|
||||||
|
|
|
||||||
|
|
@ -323,6 +323,14 @@ public sealed class RuntimeEntityDirectory
|
||||||
record.FinalPhysicsState = state;
|
record.FinalPhysicsState = state;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal bool StopMissileAfterCollision(
|
||||||
|
RuntimeEntityRecord record,
|
||||||
|
bool requireCurrentMissile)
|
||||||
|
{
|
||||||
|
EnsureKnown(record);
|
||||||
|
return record.StopMissileAfterCollision(requireCurrentMissile);
|
||||||
|
}
|
||||||
|
|
||||||
public void SetHasPartArray(RuntimeEntityRecord record, bool value)
|
public void SetHasPartArray(RuntimeEntityRecord record, bool value)
|
||||||
{
|
{
|
||||||
EnsureKnown(record);
|
EnsureKnown(record);
|
||||||
|
|
|
||||||
|
|
@ -450,6 +450,7 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
|
||||||
Physics.SetPosition.Forget(
|
Physics.SetPosition.Forget(
|
||||||
canonical,
|
canonical,
|
||||||
releasePreparedMover: true);
|
releasePreparedMover: true);
|
||||||
|
Physics.CollisionReports.Forget(canonical);
|
||||||
Physics.RemoveSpatialProjection(canonical);
|
Physics.RemoveSpatialProjection(canonical);
|
||||||
Entities.SetRemoteMotion(canonical, null);
|
Entities.SetRemoteMotion(canonical, null);
|
||||||
Entities.SetRemoteMotionBindingInProgress(canonical, false);
|
Entities.SetRemoteMotionBindingInProgress(canonical, false);
|
||||||
|
|
@ -540,6 +541,7 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
|
||||||
|
|
||||||
Entities.RefreshSnapshot(canonical, accepted);
|
Entities.RefreshSnapshot(canonical, accepted);
|
||||||
Entities.AdvancePositionAuthority(canonical);
|
Entities.AdvancePositionAuthority(canonical);
|
||||||
|
Physics.CollisionReports.LeaveWorld(canonical);
|
||||||
RuntimePlacementCancellationReceipt cancellation =
|
RuntimePlacementCancellationReceipt cancellation =
|
||||||
Physics.SetPosition.Forget(canonical);
|
Physics.SetPosition.Forget(canonical);
|
||||||
Entities.SuspendObjectClock(canonical);
|
Entities.SuspendObjectClock(canonical);
|
||||||
|
|
@ -631,6 +633,7 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
|
||||||
|
|
||||||
RuntimePlacementCancellationReceipt cancellation =
|
RuntimePlacementCancellationReceipt cancellation =
|
||||||
Physics.SetPosition.Forget(canonical);
|
Physics.SetPosition.Forget(canonical);
|
||||||
|
Physics.CollisionReports.LeaveWorld(canonical);
|
||||||
Entities.SuspendObjectClock(canonical);
|
Entities.SuspendObjectClock(canonical);
|
||||||
Entities.SetFullCell(canonical, 0u, 0u);
|
Entities.SetFullCell(canonical, 0u, 0u);
|
||||||
ulong spatialVersion = canonical.SpatialAuthorityVersion;
|
ulong spatialVersion = canonical.SpatialAuthorityVersion;
|
||||||
|
|
@ -730,6 +733,22 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
|
||||||
}
|
}
|
||||||
|
|
||||||
Entities.RefreshSnapshot(canonical, accepted);
|
Entities.RefreshSnapshot(canonical, accepted);
|
||||||
|
RetailPhysicsStateTransition preview =
|
||||||
|
RetailPhysicsStateTransitions.Apply(
|
||||||
|
canonical.FinalPhysicsState,
|
||||||
|
(PhysicsStateFlags)update.PhysicsState);
|
||||||
|
ulong priorPhysicsMutation = canonical.PhysicsStateMutationVersion;
|
||||||
|
if (preview.HiddenTransition is RetailHiddenTransition.BecameHidden)
|
||||||
|
{
|
||||||
|
Physics.CollisionReports.LeaveWorld(canonical);
|
||||||
|
if (!Entities.IsCurrent(canonical)
|
||||||
|
|| canonical.PhysicsStateMutationVersion
|
||||||
|
!= priorPhysicsMutation)
|
||||||
|
{
|
||||||
|
transition = default;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
transition = Entities.ApplyRawPhysicsState(
|
transition = Entities.ApplyRawPhysicsState(
|
||||||
canonical,
|
canonical,
|
||||||
update.PhysicsState);
|
update.PhysicsState);
|
||||||
|
|
@ -878,6 +897,7 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
|
||||||
if (!Entities.IsCurrent(canonical))
|
if (!Entities.IsCurrent(canonical))
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
|
Physics.CollisionReports.LeaveWorld(canonical);
|
||||||
Entities.SuspendObjectClock(canonical);
|
Entities.SuspendObjectClock(canonical);
|
||||||
Entities.SetFullCell(canonical, 0u, 0u);
|
Entities.SetFullCell(canonical, 0u, 0u);
|
||||||
ulong spatialVersion = canonical.SpatialAuthorityVersion;
|
ulong spatialVersion = canonical.SpatialAuthorityVersion;
|
||||||
|
|
@ -955,6 +975,7 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
|
||||||
&& active.Incarnation == delete.InstanceSequence
|
&& active.Incarnation == delete.InstanceSequence
|
||||||
&& Entities.RemoveActive(active))
|
&& Entities.RemoveActive(active))
|
||||||
{
|
{
|
||||||
|
Physics.CollisionReports.Forget(active);
|
||||||
RuntimePlacementCancellationReceipt cancellation =
|
RuntimePlacementCancellationReceipt cancellation =
|
||||||
Physics.SetPosition.Forget(
|
Physics.SetPosition.Forget(
|
||||||
active,
|
active,
|
||||||
|
|
@ -1030,9 +1051,11 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
|
||||||
return Array.Empty<RuntimeEntityRecord>();
|
return Array.Empty<RuntimeEntityRecord>();
|
||||||
|
|
||||||
_sessionClearInProgress = true;
|
_sessionClearInProgress = true;
|
||||||
Physics.SetPosition.ResetSession();
|
|
||||||
Entities.BeginSessionClear();
|
|
||||||
RuntimeEntityRecord[] active = Entities.ActiveRecords.ToArray();
|
RuntimeEntityRecord[] active = Entities.ActiveRecords.ToArray();
|
||||||
|
Physics.CollisionReports.LeaveWorldBatch(active);
|
||||||
|
Physics.SetPosition.ResetSession();
|
||||||
|
Physics.CollisionReports.ResetSession();
|
||||||
|
Entities.BeginSessionClear();
|
||||||
foreach (RuntimeEntityRecord canonical in active)
|
foreach (RuntimeEntityRecord canonical in active)
|
||||||
{
|
{
|
||||||
Physics.SetPosition.Forget(canonical);
|
Physics.SetPosition.Forget(canonical);
|
||||||
|
|
@ -1166,6 +1189,7 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
|
||||||
|
|
||||||
Entities.RefreshSnapshot(canonical, accepted);
|
Entities.RefreshSnapshot(canonical, accepted);
|
||||||
Entities.AdvancePositionAuthority(canonical);
|
Entities.AdvancePositionAuthority(canonical);
|
||||||
|
Physics.CollisionReports.LeaveWorld(canonical);
|
||||||
RuntimePlacementCancellationReceipt cancellation =
|
RuntimePlacementCancellationReceipt cancellation =
|
||||||
Physics.SetPosition.Forget(canonical);
|
Physics.SetPosition.Forget(canonical);
|
||||||
ulong positionVersion = canonical.PositionAuthorityVersion;
|
ulong positionVersion = canonical.PositionAuthorityVersion;
|
||||||
|
|
|
||||||
|
|
@ -164,6 +164,29 @@ public sealed class RuntimeEntityRecord
|
||||||
PhysicsBody.State = FinalPhysicsState;
|
PhysicsBody.State = FinalPhysicsState;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail collision reporting clears Missile, AlignPath, and PathClipped
|
||||||
|
/// directly on the live CPhysicsObj. Keep the canonical record and its
|
||||||
|
/// borrowed body in the same mutation edge.
|
||||||
|
/// </summary>
|
||||||
|
internal bool StopMissileAfterCollision(bool requireCurrentMissile)
|
||||||
|
{
|
||||||
|
const PhysicsStateFlags stopped = PhysicsStateFlags.Missile
|
||||||
|
| PhysicsStateFlags.AlignPath
|
||||||
|
| PhysicsStateFlags.PathClipped;
|
||||||
|
if (requireCurrentMissile
|
||||||
|
&& (FinalPhysicsState & PhysicsStateFlags.Missile) == 0)
|
||||||
|
return false;
|
||||||
|
PhysicsStateFlags final = FinalPhysicsState & ~stopped;
|
||||||
|
if (final == FinalPhysicsState)
|
||||||
|
return false;
|
||||||
|
PhysicsStateMutationVersion++;
|
||||||
|
FinalPhysicsState = final;
|
||||||
|
if (PhysicsBody is not null)
|
||||||
|
PhysicsBody.State = FinalPhysicsState;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
internal void RefreshDerivedState(bool refreshPosition = true)
|
internal void RefreshDerivedState(bool refreshPosition = true)
|
||||||
{
|
{
|
||||||
if (refreshPosition && Snapshot.Position is { } position)
|
if (refreshPosition && Snapshot.Position is { } position)
|
||||||
|
|
|
||||||
918
src/AcDream.Runtime/Physics/RuntimeCollisionReportingState.cs
Normal file
918
src/AcDream.Runtime/Physics/RuntimeCollisionReportingState.cs
Normal file
|
|
@ -0,0 +1,918 @@
|
||||||
|
using System.Collections.Immutable;
|
||||||
|
using AcDream.Core.Physics;
|
||||||
|
using AcDream.Runtime.Entities;
|
||||||
|
|
||||||
|
namespace AcDream.Runtime.Physics;
|
||||||
|
|
||||||
|
internal enum RuntimeCollisionReportKind
|
||||||
|
{
|
||||||
|
ObjectCollision,
|
||||||
|
ObjectCollisionEnd,
|
||||||
|
EnvironmentCollision,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Immutable presentation-free projection of one retail weenie collision
|
||||||
|
/// callback. Runtime commits the callback before an observer can re-enter.
|
||||||
|
/// </summary>
|
||||||
|
internal readonly record struct RuntimeCollisionReport(
|
||||||
|
ulong Sequence,
|
||||||
|
RuntimeCollisionReportKind Kind,
|
||||||
|
RuntimeEntityKey Recipient,
|
||||||
|
uint RecipientServerGuid,
|
||||||
|
RuntimeEntityKey? Other,
|
||||||
|
uint? OtherServerGuid,
|
||||||
|
bool RecipientWasInContact,
|
||||||
|
bool OtherWasInContact);
|
||||||
|
|
||||||
|
internal interface IRuntimeCollisionReportObserver
|
||||||
|
{
|
||||||
|
void OnCollisionReport(in RuntimeCollisionReport report);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal readonly record struct RuntimeCollisionReportingOwnershipSnapshot(
|
||||||
|
int OwnerCount,
|
||||||
|
int TrackedObjectCount,
|
||||||
|
int ReversePeerCount,
|
||||||
|
int ObserverCount,
|
||||||
|
int PendingReportCount,
|
||||||
|
int LeavingOwnerCount,
|
||||||
|
int AdmissionBlockedOwnerCount,
|
||||||
|
bool IsDispatching,
|
||||||
|
long DispatchFailureCount,
|
||||||
|
bool IsDisposed)
|
||||||
|
{
|
||||||
|
internal bool IsConverged =>
|
||||||
|
IsDisposed
|
||||||
|
&& OwnerCount == 0
|
||||||
|
&& TrackedObjectCount == 0
|
||||||
|
&& ReversePeerCount == 0
|
||||||
|
&& ObserverCount == 0
|
||||||
|
&& PendingReportCount == 0
|
||||||
|
&& LeavingOwnerCount == 0
|
||||||
|
&& AdmissionBlockedOwnerCount == 0
|
||||||
|
&& !IsDispatching;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Runtime owner for retail <c>CPhysicsObj::collision_table</c> and
|
||||||
|
/// <c>colliding_with_environment</c>.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Records are keyed by exact <see cref="RuntimeEntityKey"/> and retain the
|
||||||
|
/// peer's server GUID. A deleted incarnation therefore cannot donate contact
|
||||||
|
/// state to a later GUID reuse, while retail's missing-object collision-end
|
||||||
|
/// callback can still name the departed server object.
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed class RuntimeCollisionReportingState : IDisposable
|
||||||
|
{
|
||||||
|
private readonly RuntimeEntityDirectory _entities;
|
||||||
|
private readonly ShadowObjectRegistry _shadows;
|
||||||
|
private readonly Dictionary<RuntimeEntityKey, OwnerState> _owners = new();
|
||||||
|
private readonly Dictionary<RuntimeEntityKey, List<RuntimeEntityKey>>
|
||||||
|
_ownersByPeer = new();
|
||||||
|
private readonly Queue<PendingReport> _pendingReports = new();
|
||||||
|
private readonly HashSet<RuntimeEntityKey> _leaving = [];
|
||||||
|
private readonly HashSet<RuntimeEntityKey> _admissionBlocked = [];
|
||||||
|
private IRuntimeCollisionReportObserver[] _observers = [];
|
||||||
|
private ulong _nextSequence;
|
||||||
|
private ulong _dispatchEpoch = 1UL;
|
||||||
|
private long _dispatchFailureCount;
|
||||||
|
private bool _dispatching;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
internal RuntimeCollisionReportingState(
|
||||||
|
RuntimeEntityDirectory entities,
|
||||||
|
ShadowObjectRegistry shadows)
|
||||||
|
{
|
||||||
|
_entities = entities ?? throw new ArgumentNullException(nameof(entities));
|
||||||
|
_shadows = shadows ?? throw new ArgumentNullException(nameof(shadows));
|
||||||
|
}
|
||||||
|
|
||||||
|
internal RuntimeCollisionReportingOwnershipSnapshot CaptureOwnership()
|
||||||
|
{
|
||||||
|
int tracked = 0;
|
||||||
|
foreach ((_, OwnerState owner) in _owners)
|
||||||
|
tracked += owner.Records.Count;
|
||||||
|
return new RuntimeCollisionReportingOwnershipSnapshot(
|
||||||
|
_owners.Count,
|
||||||
|
tracked,
|
||||||
|
_ownersByPeer.Count,
|
||||||
|
_observers.Length,
|
||||||
|
_pendingReports.Count,
|
||||||
|
_leaving.Count,
|
||||||
|
_admissionBlocked.Count,
|
||||||
|
_dispatching,
|
||||||
|
_dispatchFailureCount,
|
||||||
|
_disposed);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal IDisposable Subscribe(IRuntimeCollisionReportObserver observer)
|
||||||
|
{
|
||||||
|
EnsureNotDisposed();
|
||||||
|
ArgumentNullException.ThrowIfNull(observer);
|
||||||
|
if (Array.IndexOf(_observers, observer) >= 0)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"A collision-report observer cannot be subscribed twice.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var replacement = new IRuntimeCollisionReportObserver[
|
||||||
|
_observers.Length + 1];
|
||||||
|
Array.Copy(_observers, replacement, _observers.Length);
|
||||||
|
replacement[^1] = observer;
|
||||||
|
_observers = replacement;
|
||||||
|
return new Subscription(this, observer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ports the reporting/tracking portion of retail
|
||||||
|
/// <c>CPhysicsObj::handle_all_collisions</c> (0x00514780). The return is
|
||||||
|
/// true only when at least one weenie collision callback was produced by
|
||||||
|
/// this invocation; physical collision response is deliberately separate.
|
||||||
|
/// </summary>
|
||||||
|
internal bool HandleReports(
|
||||||
|
RuntimeEntityRecord owner,
|
||||||
|
PhysicsBody ownerBody,
|
||||||
|
double physicsTime,
|
||||||
|
bool previousContact,
|
||||||
|
bool previousOnWalkable,
|
||||||
|
bool collidedWithEnvironment,
|
||||||
|
ImmutableArray<uint> collidedObjectIds)
|
||||||
|
{
|
||||||
|
EnsureNotDisposed();
|
||||||
|
ArgumentNullException.ThrowIfNull(owner);
|
||||||
|
ArgumentNullException.ThrowIfNull(ownerBody);
|
||||||
|
if (!double.IsFinite(physicsTime)
|
||||||
|
|| !TryGetCurrentParticipant(owner, ownerBody, out RuntimeEntityKey key))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool reported = false;
|
||||||
|
if (collidedObjectIds.IsDefault)
|
||||||
|
collidedObjectIds = ImmutableArray<uint>.Empty;
|
||||||
|
for (int index = 0; index < collidedObjectIds.Length; index++)
|
||||||
|
{
|
||||||
|
if (!IsCurrentParticipant(owner, ownerBody, key))
|
||||||
|
return reported;
|
||||||
|
|
||||||
|
uint collidedId = collidedObjectIds[index];
|
||||||
|
if (collidedId == 0u || collidedId == key.LocalEntityId)
|
||||||
|
continue;
|
||||||
|
if (!_shadows.TryGetCollisionOwner(
|
||||||
|
collidedId,
|
||||||
|
out _,
|
||||||
|
out bool registeredStatic))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (registeredStatic)
|
||||||
|
{
|
||||||
|
reported |= ReportEnvironment(
|
||||||
|
owner,
|
||||||
|
ownerBody,
|
||||||
|
key,
|
||||||
|
previousContact);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!TryGetCurrentParticipant(
|
||||||
|
collidedId,
|
||||||
|
out RuntimeEntityRecord target,
|
||||||
|
out PhysicsBody targetBody,
|
||||||
|
out RuntimeEntityKey targetKey))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The shadow registry proves exact collision ownership/static
|
||||||
|
// classification only. Retail evaluates every dynamic behavior
|
||||||
|
// bit from the live target CPhysicsObj, whose state may already
|
||||||
|
// be newer than a fallible presentation/shadow acknowledgement.
|
||||||
|
PhysicsStateFlags targetState = targetBody.State;
|
||||||
|
if ((targetState & PhysicsStateFlags.Static) != 0)
|
||||||
|
{
|
||||||
|
reported |= ReportEnvironment(
|
||||||
|
owner,
|
||||||
|
ownerBody,
|
||||||
|
key,
|
||||||
|
previousContact);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
OwnerState ownerState = GetOrCreateOwner(key);
|
||||||
|
bool isNew = !ownerState.Records.ContainsKey(targetKey);
|
||||||
|
ownerState.Records[targetKey] = new CollisionRecord(
|
||||||
|
physicsTime,
|
||||||
|
(targetState & PhysicsStateFlags.Ethereal) != 0,
|
||||||
|
target.ServerGuid);
|
||||||
|
if (!isNew)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
ownerState.Order.Add(targetKey);
|
||||||
|
AddReverseOwner(targetKey, key);
|
||||||
|
reported |= ReportObject(
|
||||||
|
owner,
|
||||||
|
ownerBody,
|
||||||
|
key,
|
||||||
|
target,
|
||||||
|
targetBody,
|
||||||
|
targetKey,
|
||||||
|
targetState,
|
||||||
|
previousContact);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!IsCurrentParticipant(owner, ownerBody, key))
|
||||||
|
return reported;
|
||||||
|
|
||||||
|
EndExpiredObjectCollisions(
|
||||||
|
owner,
|
||||||
|
ownerBody,
|
||||||
|
key,
|
||||||
|
physicsTime,
|
||||||
|
force: false);
|
||||||
|
if (!IsCurrentParticipant(owner, ownerBody, key))
|
||||||
|
return reported;
|
||||||
|
|
||||||
|
OwnerState? retained = TryGetOwner(key);
|
||||||
|
if (retained?.CollidingWithEnvironment == true)
|
||||||
|
{
|
||||||
|
retained.CollidingWithEnvironment = collidedWithEnvironment;
|
||||||
|
}
|
||||||
|
else if (collidedWithEnvironment
|
||||||
|
|| (!previousOnWalkable && ownerBody.OnWalkable))
|
||||||
|
{
|
||||||
|
reported |= ReportEnvironment(
|
||||||
|
owner,
|
||||||
|
ownerBody,
|
||||||
|
key,
|
||||||
|
previousContact);
|
||||||
|
}
|
||||||
|
|
||||||
|
TrimEmptyOwner(key);
|
||||||
|
return reported;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail leave-world/teleport/Hidden edge: force-end this object's own
|
||||||
|
/// collision table. Incoming peer records and the environment latch are
|
||||||
|
/// intentionally retained, matching retail object lookup/lifetime rules.
|
||||||
|
/// </summary>
|
||||||
|
internal void LeaveWorld(RuntimeEntityRecord record)
|
||||||
|
{
|
||||||
|
EnsureNotDisposed();
|
||||||
|
ArgumentNullException.ThrowIfNull(record);
|
||||||
|
if (record.Key is not { } key)
|
||||||
|
return;
|
||||||
|
if (!_admissionBlocked.Add(key))
|
||||||
|
return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
ForceEnd(record, key);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_admissionBlocked.Remove(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Session teardown admission transaction. Every exact owner is blocked
|
||||||
|
/// before the first force-end callback, so a later owner's callback cannot
|
||||||
|
/// recreate an earlier owner's contact table.
|
||||||
|
/// </summary>
|
||||||
|
internal void LeaveWorldBatch(
|
||||||
|
IReadOnlyList<RuntimeEntityRecord> records)
|
||||||
|
{
|
||||||
|
EnsureNotDisposed();
|
||||||
|
ArgumentNullException.ThrowIfNull(records);
|
||||||
|
var blocked = new List<(RuntimeEntityRecord Record, RuntimeEntityKey Key)>(
|
||||||
|
records.Count);
|
||||||
|
for (int index = 0; index < records.Count; index++)
|
||||||
|
{
|
||||||
|
if (records[index].Key is { } key
|
||||||
|
&& _admissionBlocked.Add(key))
|
||||||
|
{
|
||||||
|
blocked.Add((records[index], key));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
for (int index = 0; index < blocked.Count; index++)
|
||||||
|
ForceEnd(blocked[index].Record, blocked[index].Key);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
for (int index = 0; index < blocked.Count; index++)
|
||||||
|
_admissionBlocked.Remove(blocked[index].Key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Destruction edge. Retail first force-ends the departing object's own
|
||||||
|
/// table and then destroys its state. Other owners retain exact-key
|
||||||
|
/// records until their own expiry/force pass; they then emit the
|
||||||
|
/// missing-target self-only end with the preserved server GUID.
|
||||||
|
/// </summary>
|
||||||
|
internal void Forget(RuntimeEntityRecord record)
|
||||||
|
{
|
||||||
|
EnsureNotDisposed();
|
||||||
|
ArgumentNullException.ThrowIfNull(record);
|
||||||
|
if (record.Key is not { } key)
|
||||||
|
return;
|
||||||
|
// A session-clear batch blocks every owner before publishing the
|
||||||
|
// first force-end callback. A callback may synchronously accept the
|
||||||
|
// deletion of a later, already-blocked owner. That owner must still
|
||||||
|
// publish its own retail collision-end suffix before its table is
|
||||||
|
// forgotten; only an owner already inside ForceEnd is recursive.
|
||||||
|
if (_admissionBlocked.Contains(key))
|
||||||
|
{
|
||||||
|
if (!_leaving.Contains(key))
|
||||||
|
ForceEnd(record, key);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
LeaveWorld(record);
|
||||||
|
}
|
||||||
|
_owners.Remove(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal void ResetSession()
|
||||||
|
{
|
||||||
|
EnsureNotDisposed();
|
||||||
|
_owners.Clear();
|
||||||
|
_ownersByPeer.Clear();
|
||||||
|
_pendingReports.Clear();
|
||||||
|
_leaving.Clear();
|
||||||
|
_admissionBlocked.Clear();
|
||||||
|
_dispatchEpoch = checked(_dispatchEpoch + 1UL);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
return;
|
||||||
|
_owners.Clear();
|
||||||
|
_ownersByPeer.Clear();
|
||||||
|
_pendingReports.Clear();
|
||||||
|
_leaving.Clear();
|
||||||
|
_admissionBlocked.Clear();
|
||||||
|
_observers = [];
|
||||||
|
_dispatchEpoch = checked(_dispatchEpoch + 1UL);
|
||||||
|
_disposed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool ReportObject(
|
||||||
|
RuntimeEntityRecord owner,
|
||||||
|
PhysicsBody ownerBody,
|
||||||
|
RuntimeEntityKey ownerKey,
|
||||||
|
RuntimeEntityRecord target,
|
||||||
|
PhysicsBody targetBody,
|
||||||
|
RuntimeEntityKey targetKey,
|
||||||
|
PhysicsStateFlags targetState,
|
||||||
|
bool previousContact)
|
||||||
|
{
|
||||||
|
if ((targetState & PhysicsStateFlags.ReportAsEnvironment) != 0)
|
||||||
|
{
|
||||||
|
return ReportEnvironment(
|
||||||
|
owner,
|
||||||
|
ownerBody,
|
||||||
|
ownerKey,
|
||||||
|
previousContact);
|
||||||
|
}
|
||||||
|
|
||||||
|
PhysicsStateFlags ownerState = ownerBody.State;
|
||||||
|
bool ownerWasMissile =
|
||||||
|
(ownerState & PhysicsStateFlags.Missile) != 0;
|
||||||
|
bool ownerReported = (targetState
|
||||||
|
& PhysicsStateFlags.IgnoreCollisions) == 0
|
||||||
|
&& (ownerState & PhysicsStateFlags.ReportCollisions) != 0;
|
||||||
|
if (ownerReported)
|
||||||
|
{
|
||||||
|
Publish(new RuntimeCollisionReport(
|
||||||
|
NextSequence(),
|
||||||
|
RuntimeCollisionReportKind.ObjectCollision,
|
||||||
|
ownerKey,
|
||||||
|
owner.ServerGuid,
|
||||||
|
targetKey,
|
||||||
|
target.ServerGuid,
|
||||||
|
previousContact,
|
||||||
|
targetBody.InContact));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ownerWasMissile
|
||||||
|
&& (targetState & PhysicsStateFlags.IgnoreCollisions) == 0)
|
||||||
|
{
|
||||||
|
StopMissile(
|
||||||
|
owner,
|
||||||
|
ownerBody,
|
||||||
|
ownerKey,
|
||||||
|
requireCurrentMissile: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retail reads reciprocal eligibility after the source callback. A
|
||||||
|
// reentrant state update can therefore suppress this second report.
|
||||||
|
bool targetReported = IsCurrentParticipant(target, targetBody, targetKey)
|
||||||
|
&& (targetBody.State & PhysicsStateFlags.ReportCollisions) != 0
|
||||||
|
&& IsCurrentParticipant(owner, ownerBody, ownerKey)
|
||||||
|
&& (ownerBody.State & PhysicsStateFlags.IgnoreCollisions) == 0;
|
||||||
|
if (targetReported)
|
||||||
|
{
|
||||||
|
Publish(new RuntimeCollisionReport(
|
||||||
|
NextSequence(),
|
||||||
|
RuntimeCollisionReportKind.ObjectCollision,
|
||||||
|
targetKey,
|
||||||
|
target.ServerGuid,
|
||||||
|
ownerKey,
|
||||||
|
owner.ServerGuid,
|
||||||
|
targetBody.InContact,
|
||||||
|
previousContact));
|
||||||
|
}
|
||||||
|
return ownerReported || targetReported;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool ReportEnvironment(
|
||||||
|
RuntimeEntityRecord owner,
|
||||||
|
PhysicsBody ownerBody,
|
||||||
|
RuntimeEntityKey ownerKey,
|
||||||
|
bool previousContact)
|
||||||
|
{
|
||||||
|
OwnerState state = GetOrCreateOwner(ownerKey);
|
||||||
|
if (state.CollidingWithEnvironment)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
bool reported = (ownerBody.State
|
||||||
|
& PhysicsStateFlags.ReportCollisions) != 0;
|
||||||
|
state.CollidingWithEnvironment = true;
|
||||||
|
if (reported)
|
||||||
|
{
|
||||||
|
Publish(new RuntimeCollisionReport(
|
||||||
|
NextSequence(),
|
||||||
|
RuntimeCollisionReportKind.EnvironmentCollision,
|
||||||
|
ownerKey,
|
||||||
|
owner.ServerGuid,
|
||||||
|
Other: null,
|
||||||
|
OtherServerGuid: null,
|
||||||
|
previousContact,
|
||||||
|
OtherWasInContact: false));
|
||||||
|
}
|
||||||
|
StopMissile(owner, ownerBody, ownerKey);
|
||||||
|
return reported;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EndExpiredObjectCollisions(
|
||||||
|
RuntimeEntityRecord owner,
|
||||||
|
PhysicsBody? ownerBody,
|
||||||
|
RuntimeEntityKey ownerKey,
|
||||||
|
double physicsTime,
|
||||||
|
bool force)
|
||||||
|
{
|
||||||
|
if (!_owners.TryGetValue(ownerKey, out OwnerState? state)
|
||||||
|
|| state.Records.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<EndedCollision>? ended = null;
|
||||||
|
for (int index = 0; index < state.Order.Count; index++)
|
||||||
|
{
|
||||||
|
RuntimeEntityKey targetKey = state.Order[index];
|
||||||
|
if (!state.Records.TryGetValue(
|
||||||
|
targetKey,
|
||||||
|
out CollisionRecord collision))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
double age = physicsTime - collision.TouchedTime;
|
||||||
|
if (!force
|
||||||
|
&& !(age > 1d)
|
||||||
|
&& !(collision.Ethereal && age > 0d))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
(ended ??= []).Add(new EndedCollision(
|
||||||
|
targetKey,
|
||||||
|
collision.ServerGuid));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ended is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Retail deletes the complete expired set before issuing any end
|
||||||
|
// callback. Precommit every Runtime index for reentrant safety too.
|
||||||
|
ulong reportEpoch = _dispatchEpoch;
|
||||||
|
for (int index = 0; index < ended.Count; index++)
|
||||||
|
{
|
||||||
|
EndedCollision collision = ended[index];
|
||||||
|
state.Records.Remove(collision.Key);
|
||||||
|
state.Order.Remove(collision.Key);
|
||||||
|
RemoveReverseOwner(collision.Key, ownerKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
// One accepted delete/replace invalidates the complete precollected
|
||||||
|
// suffix. Capture the source token once for the whole callback loop;
|
||||||
|
// recapturing it per peer would let a retained teardown sidecar emit
|
||||||
|
// a second source report after the first callback accepted deletion.
|
||||||
|
ulong sourceSessionVersion = _entities.SessionLifetimeVersion;
|
||||||
|
ulong sourceLifetimeMutation =
|
||||||
|
_entities.CurrentLifetimeMutation(owner.ServerGuid);
|
||||||
|
for (int index = 0; index < ended.Count; index++)
|
||||||
|
{
|
||||||
|
if (_disposed
|
||||||
|
|| reportEpoch != _dispatchEpoch
|
||||||
|
|| _entities.SessionLifetimeVersion != sourceSessionVersion
|
||||||
|
|| _entities.CurrentLifetimeMutation(owner.ServerGuid)
|
||||||
|
!= sourceLifetimeMutation)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
EndedCollision collision = ended[index];
|
||||||
|
if (TryGetParticipant(
|
||||||
|
collision.Key,
|
||||||
|
out RuntimeEntityRecord target,
|
||||||
|
out PhysicsBody targetBody))
|
||||||
|
{
|
||||||
|
// report_object_collision_end returns immediately for a
|
||||||
|
// resolved ReportAsEnvironment target: neither side gets an
|
||||||
|
// object-end callback.
|
||||||
|
if ((targetBody.State
|
||||||
|
& PhysicsStateFlags.ReportAsEnvironment) != 0)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
PublishResolvedObjectEnd(
|
||||||
|
owner,
|
||||||
|
ownerBody,
|
||||||
|
ownerKey,
|
||||||
|
target,
|
||||||
|
targetBody,
|
||||||
|
collision.Key);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
PublishMissingObjectEnd(
|
||||||
|
owner,
|
||||||
|
ownerBody,
|
||||||
|
ownerKey,
|
||||||
|
collision.Key,
|
||||||
|
collision.ServerGuid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TrimEmptyOwner(ownerKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PublishResolvedObjectEnd(
|
||||||
|
RuntimeEntityRecord owner,
|
||||||
|
PhysicsBody? ownerBody,
|
||||||
|
RuntimeEntityKey ownerKey,
|
||||||
|
RuntimeEntityRecord target,
|
||||||
|
PhysicsBody targetBody,
|
||||||
|
RuntimeEntityKey targetKey)
|
||||||
|
{
|
||||||
|
ulong sourceSessionVersion = _entities.SessionLifetimeVersion;
|
||||||
|
ulong sourceLifetimeMutation =
|
||||||
|
_entities.CurrentLifetimeMutation(owner.ServerGuid);
|
||||||
|
ulong reportEpoch = _dispatchEpoch;
|
||||||
|
if (ownerBody is not null
|
||||||
|
&& (ownerBody.State & PhysicsStateFlags.ReportCollisions) != 0
|
||||||
|
&& IsKnownParticipant(owner, ownerBody, ownerKey))
|
||||||
|
{
|
||||||
|
Publish(new RuntimeCollisionReport(
|
||||||
|
NextSequence(),
|
||||||
|
RuntimeCollisionReportKind.ObjectCollisionEnd,
|
||||||
|
ownerKey,
|
||||||
|
owner.ServerGuid,
|
||||||
|
targetKey,
|
||||||
|
target.ServerGuid,
|
||||||
|
ownerBody.InContact,
|
||||||
|
targetBody.InContact));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((targetBody.State & PhysicsStateFlags.ReportCollisions) != 0
|
||||||
|
&& reportEpoch == _dispatchEpoch
|
||||||
|
&& !_disposed
|
||||||
|
&& _entities.SessionLifetimeVersion == sourceSessionVersion
|
||||||
|
&& _entities.CurrentLifetimeMutation(owner.ServerGuid)
|
||||||
|
== sourceLifetimeMutation
|
||||||
|
&& ownerBody is not null
|
||||||
|
&& IsKnownParticipant(owner, ownerBody, ownerKey)
|
||||||
|
&& IsKnownParticipant(target, targetBody, targetKey))
|
||||||
|
{
|
||||||
|
Publish(new RuntimeCollisionReport(
|
||||||
|
NextSequence(),
|
||||||
|
RuntimeCollisionReportKind.ObjectCollisionEnd,
|
||||||
|
targetKey,
|
||||||
|
target.ServerGuid,
|
||||||
|
ownerKey,
|
||||||
|
owner.ServerGuid,
|
||||||
|
targetBody.InContact,
|
||||||
|
ownerBody?.InContact ?? false));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PublishMissingObjectEnd(
|
||||||
|
RuntimeEntityRecord owner,
|
||||||
|
PhysicsBody? ownerBody,
|
||||||
|
RuntimeEntityKey ownerKey,
|
||||||
|
RuntimeEntityKey targetKey,
|
||||||
|
uint targetServerGuid)
|
||||||
|
{
|
||||||
|
if (ownerBody is null
|
||||||
|
|| (ownerBody.State & PhysicsStateFlags.ReportCollisions) == 0
|
||||||
|
|| !IsKnownParticipant(owner, ownerBody, ownerKey))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Publish(new RuntimeCollisionReport(
|
||||||
|
NextSequence(),
|
||||||
|
RuntimeCollisionReportKind.ObjectCollisionEnd,
|
||||||
|
ownerKey,
|
||||||
|
owner.ServerGuid,
|
||||||
|
targetKey,
|
||||||
|
targetServerGuid,
|
||||||
|
ownerBody.InContact,
|
||||||
|
OtherWasInContact: false));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StopMissile(
|
||||||
|
RuntimeEntityRecord owner,
|
||||||
|
PhysicsBody ownerBody,
|
||||||
|
RuntimeEntityKey ownerKey,
|
||||||
|
bool requireCurrentMissile = true)
|
||||||
|
{
|
||||||
|
if (!IsCurrentParticipant(owner, ownerBody, ownerKey)
|
||||||
|
|| !_entities.StopMissileAfterCollision(
|
||||||
|
owner,
|
||||||
|
requireCurrentMissile))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_shadows.UpdatePhysicsState(
|
||||||
|
ownerKey.LocalEntityId,
|
||||||
|
(uint)owner.FinalPhysicsState);
|
||||||
|
}
|
||||||
|
|
||||||
|
private OwnerState GetOrCreateOwner(RuntimeEntityKey key)
|
||||||
|
{
|
||||||
|
if (!_owners.TryGetValue(key, out OwnerState? owner))
|
||||||
|
{
|
||||||
|
owner = new OwnerState();
|
||||||
|
_owners.Add(key, owner);
|
||||||
|
}
|
||||||
|
return owner;
|
||||||
|
}
|
||||||
|
|
||||||
|
private OwnerState? TryGetOwner(RuntimeEntityKey key) =>
|
||||||
|
_owners.TryGetValue(key, out OwnerState? owner) ? owner : null;
|
||||||
|
|
||||||
|
private void TrimEmptyOwner(RuntimeEntityKey key)
|
||||||
|
{
|
||||||
|
if (_owners.TryGetValue(key, out OwnerState? owner)
|
||||||
|
&& owner.Records.Count == 0
|
||||||
|
&& !owner.CollidingWithEnvironment)
|
||||||
|
{
|
||||||
|
_owners.Remove(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddReverseOwner(
|
||||||
|
RuntimeEntityKey peer,
|
||||||
|
RuntimeEntityKey owner)
|
||||||
|
{
|
||||||
|
if (!_ownersByPeer.TryGetValue(
|
||||||
|
peer,
|
||||||
|
out List<RuntimeEntityKey>? owners))
|
||||||
|
{
|
||||||
|
owners = [];
|
||||||
|
_ownersByPeer.Add(peer, owners);
|
||||||
|
}
|
||||||
|
if (!owners.Contains(owner))
|
||||||
|
owners.Add(owner);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RemoveReverseOwner(
|
||||||
|
RuntimeEntityKey peer,
|
||||||
|
RuntimeEntityKey owner)
|
||||||
|
{
|
||||||
|
if (!_ownersByPeer.TryGetValue(
|
||||||
|
peer,
|
||||||
|
out List<RuntimeEntityKey>? owners))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
owners.Remove(owner);
|
||||||
|
if (owners.Count == 0)
|
||||||
|
_ownersByPeer.Remove(peer);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool TryGetCurrentParticipant(
|
||||||
|
RuntimeEntityRecord record,
|
||||||
|
PhysicsBody body,
|
||||||
|
out RuntimeEntityKey key)
|
||||||
|
{
|
||||||
|
key = record.Key ?? default;
|
||||||
|
return key != default
|
||||||
|
&& IsCurrentParticipant(record, body, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool TryGetCurrentParticipant(
|
||||||
|
uint localEntityId,
|
||||||
|
out RuntimeEntityRecord record,
|
||||||
|
out PhysicsBody body,
|
||||||
|
out RuntimeEntityKey key)
|
||||||
|
{
|
||||||
|
if (_entities.TryGetByLocalId(localEntityId, out record!)
|
||||||
|
&& record.PhysicsBody is { } retained
|
||||||
|
&& record.Key is { } retainedKey
|
||||||
|
&& retainedKey.LocalEntityId == localEntityId
|
||||||
|
&& !_leaving.Contains(retainedKey)
|
||||||
|
&& !_admissionBlocked.Contains(retainedKey)
|
||||||
|
&& retained.InWorld
|
||||||
|
&& (retained.State & PhysicsStateFlags.Hidden) == 0
|
||||||
|
&& _entities.IsCurrent(record))
|
||||||
|
{
|
||||||
|
body = retained;
|
||||||
|
key = retainedKey;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
body = null!;
|
||||||
|
key = default;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool TryGetParticipant(
|
||||||
|
RuntimeEntityKey key,
|
||||||
|
out RuntimeEntityRecord record,
|
||||||
|
out PhysicsBody body)
|
||||||
|
{
|
||||||
|
if (_entities.TryGetByLocalId(key.LocalEntityId, out record!)
|
||||||
|
&& record.Key == key
|
||||||
|
&& !_leaving.Contains(key)
|
||||||
|
&& _entities.IsCurrent(record)
|
||||||
|
&& record.PhysicsBody is { } retained)
|
||||||
|
{
|
||||||
|
body = retained;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
body = null!;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool IsCurrentParticipant(
|
||||||
|
RuntimeEntityRecord record,
|
||||||
|
PhysicsBody body,
|
||||||
|
RuntimeEntityKey key) =>
|
||||||
|
_entities.IsCurrent(record)
|
||||||
|
&& !_leaving.Contains(key)
|
||||||
|
&& !_admissionBlocked.Contains(key)
|
||||||
|
&& body.InWorld
|
||||||
|
&& (body.State & PhysicsStateFlags.Hidden) == 0
|
||||||
|
&& IsKnownParticipant(record, body, key);
|
||||||
|
|
||||||
|
private void ForceEnd(
|
||||||
|
RuntimeEntityRecord record,
|
||||||
|
RuntimeEntityKey key)
|
||||||
|
{
|
||||||
|
if (!_leaving.Add(key))
|
||||||
|
return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
EndExpiredObjectCollisions(
|
||||||
|
record,
|
||||||
|
record.PhysicsBody,
|
||||||
|
key,
|
||||||
|
physicsTime: 0d,
|
||||||
|
force: true);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_leaving.Remove(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool IsKnownParticipant(
|
||||||
|
RuntimeEntityRecord record,
|
||||||
|
PhysicsBody body,
|
||||||
|
RuntimeEntityKey key) =>
|
||||||
|
record.Key == key
|
||||||
|
&& ReferenceEquals(record.PhysicsBody, body)
|
||||||
|
&& _entities.TryGetByLocalId(
|
||||||
|
key.LocalEntityId,
|
||||||
|
out RuntimeEntityRecord retained)
|
||||||
|
&& ReferenceEquals(retained, record);
|
||||||
|
|
||||||
|
private ulong NextSequence() => checked(++_nextSequence);
|
||||||
|
|
||||||
|
private void Publish(in RuntimeCollisionReport report)
|
||||||
|
{
|
||||||
|
_pendingReports.Enqueue(new PendingReport(_dispatchEpoch, report));
|
||||||
|
if (_dispatching)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_dispatching = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
while (!_disposed && _pendingReports.TryDequeue(out PendingReport pending))
|
||||||
|
{
|
||||||
|
if (pending.Epoch != _dispatchEpoch)
|
||||||
|
continue;
|
||||||
|
IRuntimeCollisionReportObserver[] observers = _observers;
|
||||||
|
for (int index = 0; index < observers.Length; index++)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
observers[index].OnCollisionReport(pending.Report);
|
||||||
|
}
|
||||||
|
catch (Exception error)
|
||||||
|
{
|
||||||
|
_dispatchFailureCount++;
|
||||||
|
System.Diagnostics.Trace.TraceError(
|
||||||
|
"Runtime collision-report observer failed: {0}",
|
||||||
|
error);
|
||||||
|
}
|
||||||
|
if (_disposed || pending.Epoch != _dispatchEpoch)
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_dispatching = false;
|
||||||
|
if (_disposed)
|
||||||
|
_pendingReports.Clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Unsubscribe(IRuntimeCollisionReportObserver observer)
|
||||||
|
{
|
||||||
|
int index = Array.IndexOf(_observers, observer);
|
||||||
|
if (index < 0)
|
||||||
|
return;
|
||||||
|
if (_observers.Length == 1)
|
||||||
|
{
|
||||||
|
_observers = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var replacement = new IRuntimeCollisionReportObserver[
|
||||||
|
_observers.Length - 1];
|
||||||
|
if (index > 0)
|
||||||
|
Array.Copy(_observers, 0, replacement, 0, index);
|
||||||
|
if (index < _observers.Length - 1)
|
||||||
|
{
|
||||||
|
Array.Copy(
|
||||||
|
_observers,
|
||||||
|
index + 1,
|
||||||
|
replacement,
|
||||||
|
index,
|
||||||
|
_observers.Length - index - 1);
|
||||||
|
}
|
||||||
|
_observers = replacement;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnsureNotDisposed() =>
|
||||||
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||||
|
|
||||||
|
private sealed class OwnerState
|
||||||
|
{
|
||||||
|
internal Dictionary<RuntimeEntityKey, CollisionRecord> Records { get; }
|
||||||
|
= new();
|
||||||
|
internal List<RuntimeEntityKey> Order { get; } = [];
|
||||||
|
internal bool CollidingWithEnvironment { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly record struct CollisionRecord(
|
||||||
|
double TouchedTime,
|
||||||
|
bool Ethereal,
|
||||||
|
uint ServerGuid);
|
||||||
|
|
||||||
|
private readonly record struct EndedCollision(
|
||||||
|
RuntimeEntityKey Key,
|
||||||
|
uint ServerGuid);
|
||||||
|
|
||||||
|
private readonly record struct PendingReport(
|
||||||
|
ulong Epoch,
|
||||||
|
RuntimeCollisionReport Report);
|
||||||
|
|
||||||
|
private sealed class Subscription : IDisposable
|
||||||
|
{
|
||||||
|
private RuntimeCollisionReportingState? _owner;
|
||||||
|
private readonly IRuntimeCollisionReportObserver _observer;
|
||||||
|
|
||||||
|
internal Subscription(
|
||||||
|
RuntimeCollisionReportingState owner,
|
||||||
|
IRuntimeCollisionReportObserver observer)
|
||||||
|
{
|
||||||
|
_owner = owner;
|
||||||
|
_observer = observer;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
RuntimeCollisionReportingState? owner =
|
||||||
|
Interlocked.Exchange(ref _owner, null);
|
||||||
|
owner?.Unsubscribe(_observer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -23,6 +23,14 @@ public readonly record struct RuntimePhysicsOwnershipSnapshot(
|
||||||
int UnboundDeferredSetPositionCellCount,
|
int UnboundDeferredSetPositionCellCount,
|
||||||
int UnboundDeferredSetPositionCellOrderCount,
|
int UnboundDeferredSetPositionCellOrderCount,
|
||||||
int PreparedSetPositionMoverCount,
|
int PreparedSetPositionMoverCount,
|
||||||
|
int CollisionReportOwnerCount,
|
||||||
|
int TrackedCollisionObjectCount,
|
||||||
|
int CollisionReportReversePeerCount,
|
||||||
|
int CollisionReportObserverCount,
|
||||||
|
int PendingCollisionReportCount,
|
||||||
|
int LeavingCollisionReportOwnerCount,
|
||||||
|
int CollisionReportAdmissionBlockedOwnerCount,
|
||||||
|
bool IsCollisionReportDispatching,
|
||||||
int CollisionAdmissionCount,
|
int CollisionAdmissionCount,
|
||||||
int CollisionGenerationCount,
|
int CollisionGenerationCount,
|
||||||
bool OwnsProductionDataCache,
|
bool OwnsProductionDataCache,
|
||||||
|
|
@ -49,6 +57,14 @@ public readonly record struct RuntimePhysicsOwnershipSnapshot(
|
||||||
&& UnboundDeferredSetPositionCellCount == 0
|
&& UnboundDeferredSetPositionCellCount == 0
|
||||||
&& UnboundDeferredSetPositionCellOrderCount == 0
|
&& UnboundDeferredSetPositionCellOrderCount == 0
|
||||||
&& PreparedSetPositionMoverCount == 0
|
&& PreparedSetPositionMoverCount == 0
|
||||||
|
&& CollisionReportOwnerCount == 0
|
||||||
|
&& TrackedCollisionObjectCount == 0
|
||||||
|
&& CollisionReportReversePeerCount == 0
|
||||||
|
&& CollisionReportObserverCount == 0
|
||||||
|
&& PendingCollisionReportCount == 0
|
||||||
|
&& LeavingCollisionReportOwnerCount == 0
|
||||||
|
&& CollisionReportAdmissionBlockedOwnerCount == 0
|
||||||
|
&& !IsCollisionReportDispatching
|
||||||
&& CollisionAdmissionCount == 0
|
&& CollisionAdmissionCount == 0
|
||||||
&& CollisionGenerationCount == 0
|
&& CollisionGenerationCount == 0
|
||||||
&& OwnsProductionDataCache;
|
&& OwnsProductionDataCache;
|
||||||
|
|
@ -1063,6 +1079,9 @@ public sealed class RuntimePhysicsState : IDisposable
|
||||||
Engine.ShadowObjects.OwnerMutated += OnCollisionOwnerMutated;
|
Engine.ShadowObjects.OwnerMutated += OnCollisionOwnerMutated;
|
||||||
Engine.ShadowObjects.OwnerPrefixMembershipChanged +=
|
Engine.ShadowObjects.OwnerPrefixMembershipChanged +=
|
||||||
OnCollisionOwnerPrefixMembershipChanged;
|
OnCollisionOwnerPrefixMembershipChanged;
|
||||||
|
CollisionReports = new RuntimeCollisionReportingState(
|
||||||
|
Entities,
|
||||||
|
Engine.ShadowObjects);
|
||||||
SetPosition = new RuntimeSetPositionState(this, Entities);
|
SetPosition = new RuntimeSetPositionState(this, Entities);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1082,12 +1101,16 @@ public sealed class RuntimePhysicsState : IDisposable
|
||||||
Engine.ShadowObjects.OwnerMutated += OnCollisionOwnerMutated;
|
Engine.ShadowObjects.OwnerMutated += OnCollisionOwnerMutated;
|
||||||
Engine.ShadowObjects.OwnerPrefixMembershipChanged +=
|
Engine.ShadowObjects.OwnerPrefixMembershipChanged +=
|
||||||
OnCollisionOwnerPrefixMembershipChanged;
|
OnCollisionOwnerPrefixMembershipChanged;
|
||||||
|
CollisionReports = new RuntimeCollisionReportingState(
|
||||||
|
Entities,
|
||||||
|
Engine.ShadowObjects);
|
||||||
SetPosition = new RuntimeSetPositionState(this, Entities);
|
SetPosition = new RuntimeSetPositionState(this, Entities);
|
||||||
}
|
}
|
||||||
|
|
||||||
internal RuntimeEntityDirectory Entities { get; }
|
internal RuntimeEntityDirectory Entities { get; }
|
||||||
public PhysicsEngine Engine { get; }
|
public PhysicsEngine Engine { get; }
|
||||||
public PhysicsDataCache DataCache { get; }
|
public PhysicsDataCache DataCache { get; }
|
||||||
|
internal RuntimeCollisionReportingState CollisionReports { get; }
|
||||||
internal RuntimeSetPositionState SetPosition { get; }
|
internal RuntimeSetPositionState SetPosition { get; }
|
||||||
public int SpatialRootCount => _spatialRoots.Count;
|
public int SpatialRootCount => _spatialRoots.Count;
|
||||||
public int SpatialRemoteCount => _spatialRemotes.Count;
|
public int SpatialRemoteCount => _spatialRemotes.Count;
|
||||||
|
|
@ -1107,6 +1130,8 @@ public sealed class RuntimePhysicsState : IDisposable
|
||||||
{
|
{
|
||||||
RuntimeSetPositionOwnershipSnapshot setPosition =
|
RuntimeSetPositionOwnershipSnapshot setPosition =
|
||||||
SetPosition.CaptureOwnership();
|
SetPosition.CaptureOwnership();
|
||||||
|
RuntimeCollisionReportingOwnershipSnapshot collisionReports =
|
||||||
|
CollisionReports.CaptureOwnership();
|
||||||
return new(
|
return new(
|
||||||
Engine.LandblockCount,
|
Engine.LandblockCount,
|
||||||
Engine.ShadowObjects.RetainedRegistrationCount,
|
Engine.ShadowObjects.RetainedRegistrationCount,
|
||||||
|
|
@ -1127,6 +1152,14 @@ public sealed class RuntimePhysicsState : IDisposable
|
||||||
setPosition.UnboundDeferredCellCount,
|
setPosition.UnboundDeferredCellCount,
|
||||||
setPosition.UnboundDeferredCellOrderCount,
|
setPosition.UnboundDeferredCellOrderCount,
|
||||||
setPosition.PreparedMoverCount,
|
setPosition.PreparedMoverCount,
|
||||||
|
collisionReports.OwnerCount,
|
||||||
|
collisionReports.TrackedObjectCount,
|
||||||
|
collisionReports.ReversePeerCount,
|
||||||
|
collisionReports.ObserverCount,
|
||||||
|
collisionReports.PendingReportCount,
|
||||||
|
collisionReports.LeavingOwnerCount,
|
||||||
|
collisionReports.AdmissionBlockedOwnerCount,
|
||||||
|
collisionReports.IsDispatching,
|
||||||
_collisionAdmissions.Count,
|
_collisionAdmissions.Count,
|
||||||
_collisionGenerations.Count,
|
_collisionGenerations.Count,
|
||||||
ReferenceEquals(Engine.DataCache, DataCache),
|
ReferenceEquals(Engine.DataCache, DataCache),
|
||||||
|
|
@ -2303,6 +2336,7 @@ public sealed class RuntimePhysicsState : IDisposable
|
||||||
}
|
}
|
||||||
_preparedCollisionGenerations.Clear();
|
_preparedCollisionGenerations.Clear();
|
||||||
SetPosition.Dispose();
|
SetPosition.Dispose();
|
||||||
|
CollisionReports.Dispose();
|
||||||
_collisionOwnerJournal.Clear();
|
_collisionOwnerJournal.Clear();
|
||||||
_collisionOwnerSubscribers.Clear();
|
_collisionOwnerSubscribers.Clear();
|
||||||
Engine.Clear();
|
Engine.Clear();
|
||||||
|
|
@ -2511,6 +2545,7 @@ public sealed class RuntimePhysicsState : IDisposable
|
||||||
ulong positionAuthorityVersion,
|
ulong positionAuthorityVersion,
|
||||||
ulong spatialAuthorityVersion,
|
ulong spatialAuthorityVersion,
|
||||||
ulong velocityAuthorityVersion,
|
ulong velocityAuthorityVersion,
|
||||||
|
double physicsTime,
|
||||||
bool previousContact,
|
bool previousContact,
|
||||||
bool previousOnWalkable,
|
bool previousOnWalkable,
|
||||||
in PhysicsSetPositionCollisionReport report)
|
in PhysicsSetPositionCollisionReport report)
|
||||||
|
|
@ -2518,22 +2553,39 @@ public sealed class RuntimePhysicsState : IDisposable
|
||||||
if (!Entities.IsCurrent(record)
|
if (!Entities.IsCurrent(record)
|
||||||
|| record.PositionAuthorityVersion != positionAuthorityVersion
|
|| record.PositionAuthorityVersion != positionAuthorityVersion
|
||||||
|| record.SpatialAuthorityVersion != spatialAuthorityVersion
|
|| record.SpatialAuthorityVersion != spatialAuthorityVersion
|
||||||
|| (velocityAuthorityVersion != 0UL
|
|
||||||
&& record.VelocityAuthorityVersion
|
|
||||||
!= velocityAuthorityVersion)
|
|
||||||
|| record.PhysicsBody is not { } body)
|
|| record.PhysicsBody is not { } body)
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_ = previousContact;
|
||||||
|
_ = previousOnWalkable;
|
||||||
|
bool current = HandleSetPositionCollisionReports(
|
||||||
|
record,
|
||||||
|
positionAuthorityVersion,
|
||||||
|
spatialAuthorityVersion,
|
||||||
|
physicsTime,
|
||||||
|
previousContact: false,
|
||||||
|
previousOnWalkable: false,
|
||||||
|
collidedWithEnvironment: report.CollidedWithEnvironment,
|
||||||
|
collidedObjectIds: report.CollidedObjectIds,
|
||||||
|
collisionHandlerResult: out bool collisionHandlerResult);
|
||||||
|
if (!current)
|
||||||
|
return collisionHandlerResult;
|
||||||
|
if (velocityAuthorityVersion != 0UL
|
||||||
|
&& record.VelocityAuthorityVersion != velocityAuthorityVersion)
|
||||||
|
{
|
||||||
|
return collisionHandlerResult;
|
||||||
|
}
|
||||||
|
|
||||||
body.FramesStationaryFall = report.FramesStationaryFall;
|
body.FramesStationaryFall = report.FramesStationaryFall;
|
||||||
PhysicsObjUpdate.HandleAllCollisions(
|
PhysicsObjUpdate.HandleAllCollisions(
|
||||||
body,
|
body,
|
||||||
report.CollisionNormalValid,
|
report.CollisionNormalValid,
|
||||||
report.CollisionNormal,
|
report.CollisionNormal,
|
||||||
previousContact,
|
prevContact: false,
|
||||||
previousOnWalkable,
|
prevOnWalkable: false,
|
||||||
body.OnWalkable);
|
nowOnWalkable: body.OnWalkable);
|
||||||
body.TransientState &= ~(TransientStateFlags.StationaryFall
|
body.TransientState &= ~(TransientStateFlags.StationaryFall
|
||||||
| TransientStateFlags.StationaryStop
|
| TransientStateFlags.StationaryStop
|
||||||
| TransientStateFlags.StationaryStuck);
|
| TransientStateFlags.StationaryStuck);
|
||||||
|
|
@ -2544,14 +2596,51 @@ public sealed class RuntimePhysicsState : IDisposable
|
||||||
3 => TransientStateFlags.StationaryStuck,
|
3 => TransientStateFlags.StationaryStuck,
|
||||||
_ => TransientStateFlags.None,
|
_ => TransientStateFlags.None,
|
||||||
};
|
};
|
||||||
// Retail returns the result of collision reporting, not a collision-
|
return collisionHandlerResult;
|
||||||
// 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
|
/// <summary>
|
||||||
// already-registered reporting seam explicit for the 4B2 cutover.
|
/// Runs only retail's report/tracking half of handle_all_collisions. A
|
||||||
|
/// successful SetPosition invokes this after contact/ground callbacks and
|
||||||
|
/// before physical response and shadow reflood; invalid placement invokes
|
||||||
|
/// it before its one response pass.
|
||||||
|
/// </summary>
|
||||||
|
internal bool HandleSetPositionCollisionReports(
|
||||||
|
RuntimeEntityRecord record,
|
||||||
|
ulong positionAuthorityVersion,
|
||||||
|
ulong spatialAuthorityVersion,
|
||||||
|
double physicsTime,
|
||||||
|
bool previousContact,
|
||||||
|
bool previousOnWalkable,
|
||||||
|
bool collidedWithEnvironment,
|
||||||
|
System.Collections.Immutable.ImmutableArray<uint> collidedObjectIds,
|
||||||
|
out bool collisionHandlerResult)
|
||||||
|
{
|
||||||
|
collisionHandlerResult = false;
|
||||||
|
if (!Entities.IsCurrent(record)
|
||||||
|
|| record.PositionAuthorityVersion != positionAuthorityVersion
|
||||||
|
|| record.SpatialAuthorityVersion != spatialAuthorityVersion
|
||||||
|
|| record.PhysicsBody is not { } body)
|
||||||
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
collisionHandlerResult = CollisionReports.HandleReports(
|
||||||
|
record,
|
||||||
|
body,
|
||||||
|
physicsTime,
|
||||||
|
previousContact,
|
||||||
|
previousOnWalkable,
|
||||||
|
collidedWithEnvironment,
|
||||||
|
collidedObjectIds);
|
||||||
|
return Entities.IsCurrent(record)
|
||||||
|
&& record.PositionAuthorityVersion == positionAuthorityVersion
|
||||||
|
&& record.SpatialAuthorityVersion == spatialAuthorityVersion
|
||||||
|
&& ReferenceEquals(record.PhysicsBody, body)
|
||||||
|
&& body.InWorld
|
||||||
|
&& (body.State & PhysicsStateFlags.Hidden) == 0;
|
||||||
|
}
|
||||||
|
|
||||||
private void OnCollisionOwnerMutated(uint ownerId, ulong version)
|
private void OnCollisionOwnerMutated(uint ownerId, ulong version)
|
||||||
{
|
{
|
||||||
_ = version;
|
_ = version;
|
||||||
|
|
|
||||||
|
|
@ -213,6 +213,25 @@ internal sealed class RuntimeSetPositionState : IDisposable
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed class ContactCommitGuard(
|
||||||
|
RuntimeSetPositionState owner,
|
||||||
|
Operation operation,
|
||||||
|
RuntimeEntityRecord record,
|
||||||
|
PhysicsBody body,
|
||||||
|
ulong placementCommitVersion,
|
||||||
|
uint fullCellId)
|
||||||
|
{
|
||||||
|
internal bool IsCurrent() =>
|
||||||
|
owner.IsCanonicalPlacementCommitCurrent(
|
||||||
|
operation,
|
||||||
|
record,
|
||||||
|
body,
|
||||||
|
placementCommitVersion,
|
||||||
|
fullCellId,
|
||||||
|
requireSpatialRoot: false)
|
||||||
|
&& owner.IsCollisionReportingEligible(record, body);
|
||||||
|
}
|
||||||
|
|
||||||
private readonly RuntimePhysicsState _physics;
|
private readonly RuntimePhysicsState _physics;
|
||||||
private readonly RuntimeEntityDirectory _entities;
|
private readonly RuntimeEntityDirectory _entities;
|
||||||
private readonly Dictionary<RuntimeEntityKey, Operation> _operations = [];
|
private readonly Dictionary<RuntimeEntityKey, Operation> _operations = [];
|
||||||
|
|
@ -549,9 +568,12 @@ internal sealed class RuntimeSetPositionState : IDisposable
|
||||||
operation.PositionAuthorityVersion,
|
operation.PositionAuthorityVersion,
|
||||||
operation.SourceSpatialAuthorityVersion,
|
operation.SourceSpatialAuthorityVersion,
|
||||||
operation.SourceVelocityAuthorityVersion,
|
operation.SourceVelocityAuthorityVersion,
|
||||||
|
canonicalCommand.GameTime,
|
||||||
operation.PreviousContact,
|
operation.PreviousContact,
|
||||||
operation.PreviousOnWalkable,
|
operation.PreviousOnWalkable,
|
||||||
report));
|
report));
|
||||||
|
if (!IsCurrent(operation))
|
||||||
|
return Outcome(RuntimeSetPositionStatus.Cancelled, result, default);
|
||||||
operation.Result = result;
|
operation.Result = result;
|
||||||
if (!result.IsSuccessful)
|
if (!result.IsSuccessful)
|
||||||
{
|
{
|
||||||
|
|
@ -1236,11 +1258,14 @@ internal sealed class RuntimeSetPositionState : IDisposable
|
||||||
operation.PositionAuthorityVersion,
|
operation.PositionAuthorityVersion,
|
||||||
operation.SpatialAuthorityVersion,
|
operation.SpatialAuthorityVersion,
|
||||||
operation.SourceVelocityAuthorityVersion,
|
operation.SourceVelocityAuthorityVersion,
|
||||||
|
operation.Command.GameTime,
|
||||||
operation.PreviousContact,
|
operation.PreviousContact,
|
||||||
operation.PreviousOnWalkable,
|
operation.PreviousOnWalkable,
|
||||||
report))
|
report))
|
||||||
: InvalidResult(operation.Command.Physics);
|
: InvalidResult(operation.Command.Physics);
|
||||||
operation.CollisionGenerationReady = false;
|
operation.CollisionGenerationReady = false;
|
||||||
|
if (!IsCurrent(operation))
|
||||||
|
return;
|
||||||
if (result.IsDeferred)
|
if (result.IsDeferred)
|
||||||
{
|
{
|
||||||
operation.Result = result;
|
operation.Result = result;
|
||||||
|
|
@ -1320,18 +1345,6 @@ internal sealed class RuntimeSetPositionState : IDisposable
|
||||||
body.TransientState |= TransientStateFlags.Sliding;
|
body.TransientState |= TransientStateFlags.Sliding;
|
||||||
else
|
else
|
||||||
body.TransientState &= ~TransientStateFlags.Sliding;
|
body.TransientState &= ~TransientStateFlags.Sliding;
|
||||||
body.FramesStationaryFall = result.FramesStationaryFall;
|
|
||||||
body.TransientState &= ~(TransientStateFlags.StationaryFall
|
|
||||||
| TransientStateFlags.StationaryStop
|
|
||||||
| TransientStateFlags.StationaryStuck);
|
|
||||||
body.TransientState |= result.FramesStationaryFall switch
|
|
||||||
{
|
|
||||||
1 => TransientStateFlags.StationaryFall,
|
|
||||||
2 => TransientStateFlags.StationaryStop,
|
|
||||||
3 => TransientStateFlags.StationaryStuck,
|
|
||||||
_ => TransientStateFlags.None,
|
|
||||||
};
|
|
||||||
|
|
||||||
IRuntimeRemotePlacement? remote =
|
IRuntimeRemotePlacement? remote =
|
||||||
record.RemoteMotion as IRuntimeRemotePlacement;
|
record.RemoteMotion as IRuntimeRemotePlacement;
|
||||||
if (record.FullCellId != result.CellId)
|
if (record.FullCellId != result.CellId)
|
||||||
|
|
@ -1354,6 +1367,109 @@ internal sealed class RuntimeSetPositionState : IDisposable
|
||||||
remote.LastShadowSyncOrientation = result.Orientation;
|
remote.LastShadowSyncOrientation = result.Orientation;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
uint committedCellId = result.CellId;
|
||||||
|
bool collidedWithEnvironment = result.CollidedWithEnvironment;
|
||||||
|
System.Collections.Immutable.ImmutableArray<uint> collidedObjectIds =
|
||||||
|
result.CollidedObjectIds;
|
||||||
|
if (!IsCanonicalPlacementCommitCurrent(
|
||||||
|
operation,
|
||||||
|
record,
|
||||||
|
body,
|
||||||
|
canonicalCommitVersion,
|
||||||
|
committedCellId,
|
||||||
|
requireSpatialRoot: false))
|
||||||
|
return false;
|
||||||
|
bool contactCommitted;
|
||||||
|
if (remote is null)
|
||||||
|
{
|
||||||
|
contactCommitted = PhysicsObjUpdate.CommitSetPositionContactTransition(
|
||||||
|
body,
|
||||||
|
result.InContact,
|
||||||
|
result.OnWalkable,
|
||||||
|
operation.PreviousOnWalkable);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var guard = new ContactCommitGuard(
|
||||||
|
this,
|
||||||
|
operation,
|
||||||
|
record,
|
||||||
|
body,
|
||||||
|
canonicalCommitVersion,
|
||||||
|
committedCellId);
|
||||||
|
contactCommitted = PhysicsObjUpdate.CommitSetPositionContactTransition(
|
||||||
|
body,
|
||||||
|
result.InContact,
|
||||||
|
result.OnWalkable,
|
||||||
|
operation.PreviousOnWalkable,
|
||||||
|
remote.HitGround,
|
||||||
|
remote.LeaveGround,
|
||||||
|
guard.IsCurrent);
|
||||||
|
}
|
||||||
|
if (!contactCommitted
|
||||||
|
|| !IsCanonicalPlacementCommitCurrent(
|
||||||
|
operation,
|
||||||
|
record,
|
||||||
|
body,
|
||||||
|
canonicalCommitVersion,
|
||||||
|
committedCellId,
|
||||||
|
requireSpatialRoot: false)
|
||||||
|
|| !IsCollisionReportingEligible(record, body))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
bool reportingCurrent = _physics.HandleSetPositionCollisionReports(
|
||||||
|
record,
|
||||||
|
operation.PositionAuthorityVersion,
|
||||||
|
operation.SpatialAuthorityVersion,
|
||||||
|
operation.Command.GameTime,
|
||||||
|
operation.PreviousContact,
|
||||||
|
operation.PreviousOnWalkable,
|
||||||
|
collidedWithEnvironment,
|
||||||
|
collidedObjectIds,
|
||||||
|
out _);
|
||||||
|
if (!reportingCurrent
|
||||||
|
|| !IsCanonicalPlacementCommitCurrent(
|
||||||
|
operation,
|
||||||
|
record,
|
||||||
|
body,
|
||||||
|
canonicalCommitVersion,
|
||||||
|
committedCellId,
|
||||||
|
requireSpatialRoot: false)
|
||||||
|
|| !IsCollisionReportingEligible(record, body))
|
||||||
|
return false;
|
||||||
|
body.FramesStationaryFall = result.FramesStationaryFall;
|
||||||
|
if (IsVelocityCurrent(operation))
|
||||||
|
{
|
||||||
|
PhysicsObjUpdate.HandleAllCollisions(
|
||||||
|
body,
|
||||||
|
result.CollisionNormalValid,
|
||||||
|
result.CollisionNormal,
|
||||||
|
operation.PreviousContact,
|
||||||
|
operation.PreviousOnWalkable,
|
||||||
|
body.OnWalkable);
|
||||||
|
}
|
||||||
|
body.TransientState &= ~(TransientStateFlags.StationaryFall
|
||||||
|
| TransientStateFlags.StationaryStop
|
||||||
|
| TransientStateFlags.StationaryStuck);
|
||||||
|
body.TransientState |= result.FramesStationaryFall switch
|
||||||
|
{
|
||||||
|
1 => TransientStateFlags.StationaryFall,
|
||||||
|
2 => TransientStateFlags.StationaryStop,
|
||||||
|
3 => TransientStateFlags.StationaryStuck,
|
||||||
|
_ => TransientStateFlags.None,
|
||||||
|
};
|
||||||
|
if (remote is not null)
|
||||||
|
remote.Airborne = !body.OnWalkable;
|
||||||
|
if (!IsCanonicalPlacementCommitCurrent(
|
||||||
|
operation,
|
||||||
|
record,
|
||||||
|
body,
|
||||||
|
canonicalCommitVersion,
|
||||||
|
committedCellId,
|
||||||
|
requireSpatialRoot: false))
|
||||||
|
return false;
|
||||||
|
|
||||||
_physics.Engine.ShadowObjects.CommitSetPosition(
|
_physics.Engine.ShadowObjects.CommitSetPosition(
|
||||||
operation.Key.LocalEntityId,
|
operation.Key.LocalEntityId,
|
||||||
result.Position,
|
result.Position,
|
||||||
|
|
@ -1370,39 +1486,44 @@ internal sealed class RuntimeSetPositionState : IDisposable
|
||||||
operation.EnteringWorldFromCelllessResidence = false;
|
operation.EnteringWorldFromCelllessResidence = false;
|
||||||
CancelLostFamilyDeadlines(operation);
|
CancelLostFamilyDeadlines(operation);
|
||||||
|
|
||||||
uint committedCellId = result.CellId;
|
return IsCurrent(operation)
|
||||||
bool IsCanonicalCommitCurrent() =>
|
&& IsCanonicalPlacementCommitCurrent(
|
||||||
|
operation,
|
||||||
|
record,
|
||||||
|
body,
|
||||||
|
canonicalCommitVersion,
|
||||||
|
committedCellId,
|
||||||
|
requireSpatialRoot: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool IsCanonicalPlacementCommitCurrent(
|
||||||
|
Operation operation,
|
||||||
|
RuntimeEntityRecord record,
|
||||||
|
PhysicsBody body,
|
||||||
|
ulong placementCommitVersion,
|
||||||
|
uint fullCellId,
|
||||||
|
bool requireSpatialRoot) =>
|
||||||
_entities.IsCurrent(record)
|
_entities.IsCurrent(record)
|
||||||
&& ReferenceEquals(record.PhysicsBody, body)
|
&& ReferenceEquals(record.PhysicsBody, body)
|
||||||
&& record.PlacementCommitVersion == canonicalCommitVersion
|
&& record.PositionAuthorityVersion
|
||||||
&& record.FullCellId == committedCellId
|
== operation.PositionAuthorityVersion
|
||||||
&& _physics.IsSpatialRoot(record);
|
&& record.SpatialAuthorityVersion
|
||||||
Action? hitGround = remote is null ? null : remote.HitGround;
|
== operation.SpatialAuthorityVersion
|
||||||
Action? leaveGround = remote is null ? null : remote.LeaveGround;
|
&& record.PlacementCommitVersion == placementCommitVersion
|
||||||
if (!PhysicsObjUpdate.CommitSetPositionTransition(
|
&& record.FullCellId == fullCellId
|
||||||
body,
|
&& (!requireSpatialRoot || _physics.IsSpatialRoot(record));
|
||||||
result.InContact,
|
|
||||||
result.OnWalkable,
|
private bool IsCollisionReportingEligible(
|
||||||
result.CollisionNormalValid,
|
RuntimeEntityRecord record,
|
||||||
result.CollisionNormal,
|
PhysicsBody body) =>
|
||||||
operation.PreviousContact,
|
_entities.IsCurrent(record)
|
||||||
operation.PreviousOnWalkable,
|
&& ReferenceEquals(record.PhysicsBody, body)
|
||||||
hitGround,
|
&& body.InWorld
|
||||||
leaveGround,
|
&& (body.State & PhysicsStateFlags.Hidden) == 0;
|
||||||
IsCanonicalCommitCurrent,
|
|
||||||
() => IsCanonicalCommitCurrent()
|
|
||||||
&& IsVelocityCurrent(operation)))
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (remote is not null)
|
|
||||||
remote.Airborne = !body.OnWalkable;
|
|
||||||
return IsCurrent(operation)
|
|
||||||
&& IsCanonicalCommitCurrent();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void WithdrawCanonical(RuntimeEntityRecord record)
|
private void WithdrawCanonical(RuntimeEntityRecord record)
|
||||||
{
|
{
|
||||||
|
_physics.CollisionReports.LeaveWorld(record);
|
||||||
_physics.RemoveSpatialProjection(record);
|
_physics.RemoveSpatialProjection(record);
|
||||||
if (record.Key is { } key)
|
if (record.Key is { } key)
|
||||||
_physics.Engine.ShadowObjects.Suspend(key.LocalEntityId);
|
_physics.Engine.ShadowObjects.Suspend(key.LocalEntityId);
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,73 @@ public sealed class RuntimePhysicsOwnershipTests
|
||||||
StringComparison.Ordinal);
|
StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GraphicalAndHeadlessHostsBorrowOneRuntimeCollisionAuthority()
|
||||||
|
{
|
||||||
|
string root = FindRepositoryRoot();
|
||||||
|
string appRoot = Path.Combine(root, "src", "AcDream.App");
|
||||||
|
string headlessRoot = Path.Combine(root, "src", "AcDream.Headless");
|
||||||
|
string gameWindow = File.ReadAllText(Path.Combine(
|
||||||
|
appRoot,
|
||||||
|
"Rendering",
|
||||||
|
"GameWindow.cs"));
|
||||||
|
string headlessProjection = File.ReadAllText(Path.Combine(
|
||||||
|
headlessRoot,
|
||||||
|
"Hosting",
|
||||||
|
"HeadlessSessionWorldProjection.cs"));
|
||||||
|
string productionHosts = string.Join(
|
||||||
|
"\n",
|
||||||
|
Directory.EnumerateFiles(
|
||||||
|
appRoot,
|
||||||
|
"*.cs",
|
||||||
|
SearchOption.AllDirectories)
|
||||||
|
.Concat(Directory.EnumerateFiles(
|
||||||
|
headlessRoot,
|
||||||
|
"*.cs",
|
||||||
|
SearchOption.AllDirectories))
|
||||||
|
.Select(File.ReadAllText));
|
||||||
|
|
||||||
|
Assert.Contains(
|
||||||
|
"private RuntimeEntityObjectLifetime _runtimeEntityObjects =>",
|
||||||
|
gameWindow,
|
||||||
|
StringComparison.Ordinal);
|
||||||
|
Assert.Contains(
|
||||||
|
"_runtime.EntityObjects;",
|
||||||
|
gameWindow,
|
||||||
|
StringComparison.Ordinal);
|
||||||
|
Assert.Contains(
|
||||||
|
"_runtimeEntityObjects.Physics",
|
||||||
|
gameWindow,
|
||||||
|
StringComparison.Ordinal);
|
||||||
|
Assert.Contains(
|
||||||
|
"private readonly GameRuntime _runtime;",
|
||||||
|
headlessProjection,
|
||||||
|
StringComparison.Ordinal);
|
||||||
|
Assert.Contains(
|
||||||
|
"_runtime.EntityObjects.Physics",
|
||||||
|
headlessProjection,
|
||||||
|
StringComparison.Ordinal);
|
||||||
|
|
||||||
|
// Hosts may project reports later, but they must never own a second
|
||||||
|
// collision table or reproduce Runtime's return/error heuristic.
|
||||||
|
Assert.DoesNotContain(
|
||||||
|
"new RuntimeCollisionReportingState",
|
||||||
|
productionHosts,
|
||||||
|
StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotContain(
|
||||||
|
"class RuntimeCollisionReportingState",
|
||||||
|
productionHosts,
|
||||||
|
StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotContain(
|
||||||
|
"HandleSetPositionCollisions(",
|
||||||
|
productionHosts,
|
||||||
|
StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotContain(
|
||||||
|
"CollidingWithEnvironment",
|
||||||
|
productionHosts,
|
||||||
|
StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void AppPhysicsFilesArePresentationAndPreparedAssetAdapters()
|
public void AppPhysicsFilesArePresentationAndPreparedAssetAdapters()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue