From 237d1184d2bdf6553a2f23a0fad0ee83e21d17fd Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 1 Aug 2026 00:15:11 +0200 Subject: [PATCH] feat(runtime): own SetPosition collision reports --- docs/architecture/acdream-architecture.md | 11 +- .../retail-divergence-register.md | 4 +- docs/plans/2026-04-11-roadmap.md | 21 +- docs/plans/2026-05-12-milestones.md | 16 +- .../2026-07-31-canonical-set-position.md | 28 +- ...7-31-remaining-physics-campaign-handoff.md | 6 + ...et-position-collision-reporting-handoff.md | 243 +++ ...untime-set-position-collision-reporting.md | 182 ++ memory/project_collision_port.md | 20 + src/AcDream.Core/Physics/PhysicsObjUpdate.cs | 72 +- .../Physics/ShadowObjectRegistry.cs | 26 + .../Entities/RuntimeEntityDirectory.cs | 8 + .../Entities/RuntimeEntityObjectLifetime.cs | 28 +- .../Entities/RuntimeEntityRecord.cs | 23 + .../Physics/RuntimeCollisionReportingState.cs | 918 ++++++++ .../Physics/RuntimePhysicsState.cs | 113 +- .../Physics/RuntimeSetPositionState.cs | 201 +- .../Runtime/RuntimePhysicsOwnershipTests.cs | 67 + .../RuntimeCollisionReportingStateTests.cs | 1860 +++++++++++++++++ 19 files changed, 3744 insertions(+), 103 deletions(-) create mode 100644 docs/research/2026-07-31-runtime-set-position-collision-reporting-handoff.md create mode 100644 docs/research/2026-07-31-runtime-set-position-collision-reporting.md create mode 100644 src/AcDream.Runtime/Physics/RuntimeCollisionReportingState.cs create mode 100644 tests/AcDream.Runtime.Tests/Physics/RuntimeCollisionReportingStateTests.cs diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md index c360f637..4caf2952 100644 --- a/docs/architecture/acdream-architecture.md +++ b/docs/architecture/acdream-architecture.md @@ -241,6 +241,9 @@ src/ Physics/ RuntimePhysicsState.cs -> per-session engine/cache/scratch/shadows, collision receipts, bodies/hosts/worksets + RuntimeCollisionReportingState.cs -> exact-key retail collision table, + environment latch, ordered callbacks, and + SetPosition report-result ownership RuntimeSetPositionState.cs -> exact placement/lost-cell operations, authored mover retention, ordered host receipts, and collision-generation wake @@ -336,9 +339,11 @@ src/ ``` The 4B2 production SetPosition routes and shared local-controller body remain -dormant until exact authored mover preparation, collision-report return, -presentation-only rebucketing, placement-prefix quiescence, and an atomic -Runtime body/controller publication transaction land as one reviewed cutover. +dormant. Runtime now owns the exact collision table, environment latch, and +report-result semantics needed by that cutover. Activation still waits for +exact authored mover preparation, presentation-only rebucketing, +placement-prefix quiescence, and an atomic Runtime body/controller publication +transaction to land as one reviewed cutover. --- diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 38c216af..0e5decb4 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -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-52 | **Filed at Campaign N slice N6 (2026-07-29).** The inbound fragment assembler evicts incomplete partial messages 60 s after their last ACCEPTED fragment (swept on retail's 5 s flush cadence from `ReliableTransport.Sweep`) and remembers the last 64 completed multi-fragment sequences in a ring so a late duplicate fragment of an already-completed message drops instead of allocating a fresh partial that can never complete. Retail's prune target and horizon differ: its 5 s-TTL `FlushTimedOutEphInfo` table holds ephemeral-blob ORDERING stamps (the AD-49 deferral), not partial payloads. | `src/AcDream.Core.Net/Packets/FragmentAssembler.cs` (`SweepExpired`, `PartialTtlSeconds`, `CompletedRingSize`); cadence in `src/AcDream.Core.Net/Transport/ReliableTransport.cs` (`AssemblerSweepSeconds`) | N4's RejectRetransmit abandonment made an unrecoverable partial a REACHABLE permanent state: ACE pruned a fragment-bearing packet from its 120 s S2C cache and told us to stop asking, so that blob can never complete — without a TTL it leaks for the session's lifetime. 60 s is ≫ every recovery horizon (0.6 s NAK cadence, ACE's 2 s ack, the 120 s cache) and the stamp refreshes on every accepted fragment (retail's own re-stamp rule, `ArrivedEphInfo::UpdateNetBlobID @ 0x0054AE00`), so only a server-abandoned partial can age out — a merely-slow one cannot. The ring is bounded (64 × 4 B) and its only false negative (a duplicate arriving after 64 later completions) degrades to the pre-N6 behavior, now reclaimed by the TTL. | If ACE ever legitimately re-served a fragment of a completed message under a REUSED fragment sequence within the ring window, it would be dropped — but fragment sequences are strictly monotonic per session (ACE SessionConnectionData.FragmentSequence), so reuse cannot happen inside one connection. An evicted partial whose fragments later straggle in re-partials and re-evicts — bounded churn, no corruption. | `Indicator::FlushTimedOutEphInfo @ 0x0054A3D0` (the 5.0 s flush gate at 0x0054A3DC); `ArrivedEphInfo::fTimedOut @ 0x0054AE30` (per-entry 5.0 s TTL); `ArrivedEphInfo::UpdateNetBlobID @ 0x0054AE00` (re-stamp on update); retail has no partial-payload TTL — its blob layer trusts its own NAK persistence, which N4's ACE-mandated abandonment (`SharedNet::HandleEmptyAck @ 0x005448F0`) breaks | | AD-38 | Outgoing teleport viewports retire when retail's quantized animation level exceeds the last captured visible level 1022 (index 96), suppressing levels 1023/1024 up to 20.2 ms before retail's literal `elapsed >= 1.0` state edge. Incoming fades retain the exact timer. | `src/AcDream.Core/World/TeleportAnimSequencer.cs` (`OutgoingViewportReachedTerminalProjection`) | An uncapped 2000 FPS pass can publish the finite tunnel at levels 1023/1024 even though the paired 2013 retail capture switches viewports after 1022. The table-level cutover preserves the captured visible viewport ordering without throttling the application. | Exit sound, viewport replacement, and logout tunnel entry can occur at most two easing-table quanta (about 20.2 ms) earlier than retail's logical timer. | `UIGlobals::GetAnimLevel @ 0x004EE540`; `gmSmartBoxUI::UseTime @ 0x004D6E30`; paired retail/acdream captures documented in `docs/research/2026-07-15-retail-portal-space-pseudocode.md` | -| AD-1 | **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-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 | @@ -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 | |---|---|---|---|---|---| -| 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-4~~ | **RETIRED 2026-07-31 (Campaign P Slice 1B).** `EdgeSlideAfterStepDownFailed` now evaluates retail Branch 1 (`!OnWalkable || !EdgeSlide` → restore + `OK_TS`) before the steep-contact `CliffSlide` branch. The former compensation is removed. | `src/AcDream.Core/Physics/TransitionTypes.cs` (`EdgeSlideAfterStepDownFailed`); `tests/AcDream.Core.Tests/Physics/RetailEdgeResponseOrderingTests.cs` | — | — | `CTransition::edge_slide` 0x0050B3D0, named-retail pseudo-C pc:273001–273090 | diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index a00872c4..f631d63a 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -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, crowd, two-client remote/door/portal, and shallow-water behavior. The user 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 -exact-location capture. World-interaction Slice 5 vendor browsing resumes. +#274. The later exact-location #273 tight-gap gate is now fixed and accepted. + +**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 [`2026-07-23-world-interaction-completion.md`](2026-07-23-world-interaction-completion.md). diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md index 0044ae4b..5c22e9c5 100644 --- a/docs/plans/2026-05-12-milestones.md +++ b/docs/plans/2026-05-12-milestones.md @@ -87,10 +87,18 @@ program: spell-bar overflow, status Use/Assess, assessment information, equipped-child picking, vendor browsing, and authoritative vendor transactions. This is deliberately using the extracted interaction owners and canonical shared main-panel host before quest/emote/character-creation bodies -broaden the feature surface. Slices 1–4 are user-accepted. Campaign P closed -on 2026-07-31 with tight-gap collision clearance (#273) and the deferred -restricted-house gate (#274) explicitly carried; resume at Slice 5 vendor -browsing. +broaden the feature surface. Slices 1–4 are user-accepted. Campaign P's +connected feel matrix closed on 2026-07-31 with tight-gap collision clearance +(#273) and the deferred restricted-house gate (#274) explicitly carried. The +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 Slices A–D: corrected measurement, prepared-package bake/dedup, package-only diff --git a/docs/research/2026-07-31-canonical-set-position.md b/docs/research/2026-07-31-canonical-set-position.md index f738c038..af957e30 100644 --- a/docs/research/2026-07-31-canonical-set-position.md +++ b/docs/research/2026-07-31-canonical-set-position.md @@ -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 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; 4B2 must bind it to active `RuntimeWorldTransitState` generation, teleport sequence, destination, and host acknowledgement before reveal. -- Runtime applies the canonical collision-report state but fails the handler - return closed. Retail returns report/track success, not collision presence; - the per-object report/tracking owner required for that boolean is not yet in - Runtime, and the former environment/object-presence guess is forbidden. + +The former collision-report boundary is closed by +`RuntimeCollisionReportingState`. Runtime now owns retail's exact-key object +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 @@ -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 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 -the dormant SetPosition owner yet. The cutover remains blocked on the real -retail collision report/tracking return, exact ordered Setup spheres/scale/ -step heights/flags/cell-local preparation, presentation-only rebucketing, and -placement-prefix quiescence before collision retirement. AP-1 and AD-1 remain -open until those prerequisites and every production route land together. +the dormant SetPosition owner yet. The cutover remains blocked on exact +ordered Setup spheres/scale/step heights/flags/cell-local preparation, +presentation-only rebucketing, and placement-prefix quiescence before +collision retirement. AP-1 and AD-1 remain open until those prerequisites and +every production route land together. 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- diff --git a/docs/research/2026-07-31-remaining-physics-campaign-handoff.md b/docs/research/2026-07-31-remaining-physics-campaign-handoff.md index 2593ce45..ac3cd0d5 100644 --- a/docs/research/2026-07-31-remaining-physics-campaign-handoff.md +++ b/docs/research/2026-07-31-remaining-physics-campaign-handoff.md @@ -1,5 +1,11 @@ # 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 This is the deliberate handoff boundary requested after placement Slice 4B2 diff --git a/docs/research/2026-07-31-runtime-set-position-collision-reporting-handoff.md b/docs/research/2026-07-31-runtime-set-position-collision-reporting-handoff.md new file mode 100644 index 00000000..5fdc05d3 --- /dev/null +++ b/docs/research/2026-07-31-runtime-set-position-collision-reporting-handoff.md @@ -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. diff --git a/docs/research/2026-07-31-runtime-set-position-collision-reporting.md b/docs/research/2026-07-31-runtime-set-position-collision-reporting.md new file mode 100644 index 00000000..9d280c67 --- /dev/null +++ b/docs/research/2026-07-31-runtime-set-position-collision-reporting.md @@ -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. diff --git a/memory/project_collision_port.md b/memory/project_collision_port.md index 6ee52a74..5d8409ed 100644 --- a/memory/project_collision_port.md +++ b/memory/project_collision_port.md @@ -1,5 +1,25 @@ # 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) The collision system is no longer a pure placeholder and should not be treated diff --git a/src/AcDream.Core/Physics/PhysicsObjUpdate.cs b/src/AcDream.Core/Physics/PhysicsObjUpdate.cs index fd51c73e..afd699ab 100644 --- a/src/AcDream.Core/Physics/PhysicsObjUpdate.cs +++ b/src/AcDream.Core/Physics/PhysicsObjUpdate.cs @@ -81,14 +81,53 @@ public static class PhysicsObjUpdate Action? leaveGround = null, Func? isCurrent = null, Func? 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; + } + + /// + /// Commits retail's Contact/OnWalkable and HitGround/LeaveGround prefix, + /// stopping immediately before handle_all_collisions. Runtime uses + /// this seam to run the canonical collision-table reports without adding + /// another per-placement delegate allocation. + /// + public static bool CommitSetPositionContactTransition( + PhysicsBody body, + bool inContact, + bool onWalkable, + bool previousOnWalkable, + Action? hitGround = null, + Action? leaveGround = null, + Func? isCurrent = null) { 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) body.TransientState |= TransientStateFlags.OnWalkable; else @@ -106,10 +145,6 @@ public static class PhysicsObjUpdate else 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) body.TransientState |= TransientStateFlags.WaterContact; else @@ -128,21 +163,6 @@ public static class PhysicsObjUpdate return false; } 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; } @@ -179,14 +199,14 @@ public static class PhysicsObjUpdate // 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 // false), exactly as retail. - bool sledding = body.State.HasFlag(PhysicsStateFlags.Sledding); + bool sledding = (body.State & PhysicsStateFlags.Sledding) != 0; bool shouldReflect = !(prevOnWalkable && nowOnWalkable && !sledding); if (body.FramesStationaryFall <= 1) { if (shouldReflect && collisionNormalValid) { - if (body.State.HasFlag(PhysicsStateFlags.Inelastic)) + if ((body.State & PhysicsStateFlags.Inelastic) != 0) { body.Velocity = Vector3.Zero; // pc:282720-282722 } diff --git a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs index 874addd3..845f98b2 100644 --- a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs +++ b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs @@ -1563,6 +1563,32 @@ public sealed class ShadowObjectRegistry internal bool HasLogicalOwner(uint entityId) => _entityReg.ContainsKey(entityId); + /// + /// Returns the exact collision identity consumed by retail + /// CPhysicsObj::track_object_collision. 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. + /// + 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) => _prefixOwnerSlots.TryGetValue( landblockId & 0xFFFF0000u, diff --git a/src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs b/src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs index 73308b39..578a558b 100644 --- a/src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs +++ b/src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs @@ -323,6 +323,14 @@ public sealed class RuntimeEntityDirectory record.FinalPhysicsState = state; } + internal bool StopMissileAfterCollision( + RuntimeEntityRecord record, + bool requireCurrentMissile) + { + EnsureKnown(record); + return record.StopMissileAfterCollision(requireCurrentMissile); + } + public void SetHasPartArray(RuntimeEntityRecord record, bool value) { EnsureKnown(record); diff --git a/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs b/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs index 5a43aa60..951c189f 100644 --- a/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs +++ b/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs @@ -450,6 +450,7 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable Physics.SetPosition.Forget( canonical, releasePreparedMover: true); + Physics.CollisionReports.Forget(canonical); Physics.RemoveSpatialProjection(canonical); Entities.SetRemoteMotion(canonical, null); Entities.SetRemoteMotionBindingInProgress(canonical, false); @@ -540,6 +541,7 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable Entities.RefreshSnapshot(canonical, accepted); Entities.AdvancePositionAuthority(canonical); + Physics.CollisionReports.LeaveWorld(canonical); RuntimePlacementCancellationReceipt cancellation = Physics.SetPosition.Forget(canonical); Entities.SuspendObjectClock(canonical); @@ -631,6 +633,7 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable RuntimePlacementCancellationReceipt cancellation = Physics.SetPosition.Forget(canonical); + Physics.CollisionReports.LeaveWorld(canonical); Entities.SuspendObjectClock(canonical); Entities.SetFullCell(canonical, 0u, 0u); ulong spatialVersion = canonical.SpatialAuthorityVersion; @@ -730,6 +733,22 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable } 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( canonical, update.PhysicsState); @@ -878,6 +897,7 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable if (!Entities.IsCurrent(canonical)) return false; + Physics.CollisionReports.LeaveWorld(canonical); Entities.SuspendObjectClock(canonical); Entities.SetFullCell(canonical, 0u, 0u); ulong spatialVersion = canonical.SpatialAuthorityVersion; @@ -955,6 +975,7 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable && active.Incarnation == delete.InstanceSequence && Entities.RemoveActive(active)) { + Physics.CollisionReports.Forget(active); RuntimePlacementCancellationReceipt cancellation = Physics.SetPosition.Forget( active, @@ -1030,9 +1051,11 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable return Array.Empty(); _sessionClearInProgress = true; - Physics.SetPosition.ResetSession(); - Entities.BeginSessionClear(); RuntimeEntityRecord[] active = Entities.ActiveRecords.ToArray(); + Physics.CollisionReports.LeaveWorldBatch(active); + Physics.SetPosition.ResetSession(); + Physics.CollisionReports.ResetSession(); + Entities.BeginSessionClear(); foreach (RuntimeEntityRecord canonical in active) { Physics.SetPosition.Forget(canonical); @@ -1166,6 +1189,7 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable Entities.RefreshSnapshot(canonical, accepted); Entities.AdvancePositionAuthority(canonical); + Physics.CollisionReports.LeaveWorld(canonical); RuntimePlacementCancellationReceipt cancellation = Physics.SetPosition.Forget(canonical); ulong positionVersion = canonical.PositionAuthorityVersion; diff --git a/src/AcDream.Runtime/Entities/RuntimeEntityRecord.cs b/src/AcDream.Runtime/Entities/RuntimeEntityRecord.cs index f180b915..55b66168 100644 --- a/src/AcDream.Runtime/Entities/RuntimeEntityRecord.cs +++ b/src/AcDream.Runtime/Entities/RuntimeEntityRecord.cs @@ -164,6 +164,29 @@ public sealed class RuntimeEntityRecord PhysicsBody.State = FinalPhysicsState; } + /// + /// 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. + /// + 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) { if (refreshPosition && Snapshot.Position is { } position) diff --git a/src/AcDream.Runtime/Physics/RuntimeCollisionReportingState.cs b/src/AcDream.Runtime/Physics/RuntimeCollisionReportingState.cs new file mode 100644 index 00000000..cbd61c95 --- /dev/null +++ b/src/AcDream.Runtime/Physics/RuntimeCollisionReportingState.cs @@ -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, +} + +/// +/// Immutable presentation-free projection of one retail weenie collision +/// callback. Runtime commits the callback before an observer can re-enter. +/// +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; +} + +/// +/// Runtime owner for retail CPhysicsObj::collision_table and +/// colliding_with_environment. +/// +/// +/// Records are keyed by exact 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. +/// +internal sealed class RuntimeCollisionReportingState : IDisposable +{ + private readonly RuntimeEntityDirectory _entities; + private readonly ShadowObjectRegistry _shadows; + private readonly Dictionary _owners = new(); + private readonly Dictionary> + _ownersByPeer = new(); + private readonly Queue _pendingReports = new(); + private readonly HashSet _leaving = []; + private readonly HashSet _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); + } + + /// + /// Ports the reporting/tracking portion of retail + /// CPhysicsObj::handle_all_collisions (0x00514780). The return is + /// true only when at least one weenie collision callback was produced by + /// this invocation; physical collision response is deliberately separate. + /// + internal bool HandleReports( + RuntimeEntityRecord owner, + PhysicsBody ownerBody, + double physicsTime, + bool previousContact, + bool previousOnWalkable, + bool collidedWithEnvironment, + ImmutableArray 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.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; + } + + /// + /// 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. + /// + 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); + } + } + + /// + /// 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. + /// + internal void LeaveWorldBatch( + IReadOnlyList 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); + } + } + + /// + /// 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. + /// + 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? 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? 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? 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 Records { get; } + = new(); + internal List 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); + } + } +} diff --git a/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs b/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs index 3df6294c..e67465c5 100644 --- a/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs +++ b/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs @@ -23,6 +23,14 @@ public readonly record struct RuntimePhysicsOwnershipSnapshot( int UnboundDeferredSetPositionCellCount, int UnboundDeferredSetPositionCellOrderCount, int PreparedSetPositionMoverCount, + int CollisionReportOwnerCount, + int TrackedCollisionObjectCount, + int CollisionReportReversePeerCount, + int CollisionReportObserverCount, + int PendingCollisionReportCount, + int LeavingCollisionReportOwnerCount, + int CollisionReportAdmissionBlockedOwnerCount, + bool IsCollisionReportDispatching, int CollisionAdmissionCount, int CollisionGenerationCount, bool OwnsProductionDataCache, @@ -49,6 +57,14 @@ public readonly record struct RuntimePhysicsOwnershipSnapshot( && UnboundDeferredSetPositionCellCount == 0 && UnboundDeferredSetPositionCellOrderCount == 0 && PreparedSetPositionMoverCount == 0 + && CollisionReportOwnerCount == 0 + && TrackedCollisionObjectCount == 0 + && CollisionReportReversePeerCount == 0 + && CollisionReportObserverCount == 0 + && PendingCollisionReportCount == 0 + && LeavingCollisionReportOwnerCount == 0 + && CollisionReportAdmissionBlockedOwnerCount == 0 + && !IsCollisionReportDispatching && CollisionAdmissionCount == 0 && CollisionGenerationCount == 0 && OwnsProductionDataCache; @@ -1063,6 +1079,9 @@ public sealed class RuntimePhysicsState : IDisposable Engine.ShadowObjects.OwnerMutated += OnCollisionOwnerMutated; Engine.ShadowObjects.OwnerPrefixMembershipChanged += OnCollisionOwnerPrefixMembershipChanged; + CollisionReports = new RuntimeCollisionReportingState( + Entities, + Engine.ShadowObjects); SetPosition = new RuntimeSetPositionState(this, Entities); } @@ -1082,12 +1101,16 @@ public sealed class RuntimePhysicsState : IDisposable Engine.ShadowObjects.OwnerMutated += OnCollisionOwnerMutated; Engine.ShadowObjects.OwnerPrefixMembershipChanged += OnCollisionOwnerPrefixMembershipChanged; + CollisionReports = new RuntimeCollisionReportingState( + Entities, + Engine.ShadowObjects); SetPosition = new RuntimeSetPositionState(this, Entities); } internal RuntimeEntityDirectory Entities { get; } public PhysicsEngine Engine { get; } public PhysicsDataCache DataCache { get; } + internal RuntimeCollisionReportingState CollisionReports { get; } internal RuntimeSetPositionState SetPosition { get; } public int SpatialRootCount => _spatialRoots.Count; public int SpatialRemoteCount => _spatialRemotes.Count; @@ -1107,6 +1130,8 @@ public sealed class RuntimePhysicsState : IDisposable { RuntimeSetPositionOwnershipSnapshot setPosition = SetPosition.CaptureOwnership(); + RuntimeCollisionReportingOwnershipSnapshot collisionReports = + CollisionReports.CaptureOwnership(); return new( Engine.LandblockCount, Engine.ShadowObjects.RetainedRegistrationCount, @@ -1127,6 +1152,14 @@ public sealed class RuntimePhysicsState : IDisposable setPosition.UnboundDeferredCellCount, setPosition.UnboundDeferredCellOrderCount, setPosition.PreparedMoverCount, + collisionReports.OwnerCount, + collisionReports.TrackedObjectCount, + collisionReports.ReversePeerCount, + collisionReports.ObserverCount, + collisionReports.PendingReportCount, + collisionReports.LeavingOwnerCount, + collisionReports.AdmissionBlockedOwnerCount, + collisionReports.IsDispatching, _collisionAdmissions.Count, _collisionGenerations.Count, ReferenceEquals(Engine.DataCache, DataCache), @@ -2303,6 +2336,7 @@ public sealed class RuntimePhysicsState : IDisposable } _preparedCollisionGenerations.Clear(); SetPosition.Dispose(); + CollisionReports.Dispose(); _collisionOwnerJournal.Clear(); _collisionOwnerSubscribers.Clear(); Engine.Clear(); @@ -2511,6 +2545,7 @@ public sealed class RuntimePhysicsState : IDisposable ulong positionAuthorityVersion, ulong spatialAuthorityVersion, ulong velocityAuthorityVersion, + double physicsTime, bool previousContact, bool previousOnWalkable, in PhysicsSetPositionCollisionReport report) @@ -2518,22 +2553,39 @@ public sealed class RuntimePhysicsState : IDisposable if (!Entities.IsCurrent(record) || record.PositionAuthorityVersion != positionAuthorityVersion || record.SpatialAuthorityVersion != spatialAuthorityVersion - || (velocityAuthorityVersion != 0UL - && record.VelocityAuthorityVersion - != velocityAuthorityVersion) || record.PhysicsBody is not { } body) { 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; PhysicsObjUpdate.HandleAllCollisions( body, report.CollisionNormalValid, report.CollisionNormal, - previousContact, - previousOnWalkable, - body.OnWalkable); + prevContact: false, + prevOnWalkable: false, + nowOnWalkable: body.OnWalkable); body.TransientState &= ~(TransientStateFlags.StationaryFall | TransientStateFlags.StationaryStop | TransientStateFlags.StationaryStuck); @@ -2544,12 +2596,49 @@ public sealed class RuntimePhysicsState : IDisposable 3 => TransientStateFlags.StationaryStuck, _ => TransientStateFlags.None, }; - // Retail returns the result of collision reporting, not a collision- - // presence guess. Runtime does not yet own the per-object report/ - // tracking table required to reproduce that return value, so fail - // closed. This preserves ordinary placement rejection and leaves the - // already-registered reporting seam explicit for the 4B2 cutover. - return false; + return collisionHandlerResult; + } + + /// + /// 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. + /// + internal bool HandleSetPositionCollisionReports( + RuntimeEntityRecord record, + ulong positionAuthorityVersion, + ulong spatialAuthorityVersion, + double physicsTime, + bool previousContact, + bool previousOnWalkable, + bool collidedWithEnvironment, + System.Collections.Immutable.ImmutableArray collidedObjectIds, + out bool collisionHandlerResult) + { + collisionHandlerResult = false; + if (!Entities.IsCurrent(record) + || record.PositionAuthorityVersion != positionAuthorityVersion + || record.SpatialAuthorityVersion != spatialAuthorityVersion + || record.PhysicsBody is not { } body) + { + 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) diff --git a/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs b/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs index 302318e1..b0692f5a 100644 --- a/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs +++ b/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs @@ -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 RuntimeEntityDirectory _entities; private readonly Dictionary _operations = []; @@ -549,9 +568,12 @@ internal sealed class RuntimeSetPositionState : IDisposable operation.PositionAuthorityVersion, operation.SourceSpatialAuthorityVersion, operation.SourceVelocityAuthorityVersion, + canonicalCommand.GameTime, operation.PreviousContact, operation.PreviousOnWalkable, report)); + if (!IsCurrent(operation)) + return Outcome(RuntimeSetPositionStatus.Cancelled, result, default); operation.Result = result; if (!result.IsSuccessful) { @@ -1236,11 +1258,14 @@ internal sealed class RuntimeSetPositionState : IDisposable operation.PositionAuthorityVersion, operation.SpatialAuthorityVersion, operation.SourceVelocityAuthorityVersion, + operation.Command.GameTime, operation.PreviousContact, operation.PreviousOnWalkable, report)) : InvalidResult(operation.Command.Physics); operation.CollisionGenerationReady = false; + if (!IsCurrent(operation)) + return; if (result.IsDeferred) { operation.Result = result; @@ -1320,18 +1345,6 @@ internal sealed class RuntimeSetPositionState : IDisposable body.TransientState |= TransientStateFlags.Sliding; else body.TransientState &= ~TransientStateFlags.Sliding; - body.FramesStationaryFall = result.FramesStationaryFall; - body.TransientState &= ~(TransientStateFlags.StationaryFall - | TransientStateFlags.StationaryStop - | TransientStateFlags.StationaryStuck); - body.TransientState |= result.FramesStationaryFall switch - { - 1 => TransientStateFlags.StationaryFall, - 2 => TransientStateFlags.StationaryStop, - 3 => TransientStateFlags.StationaryStuck, - _ => TransientStateFlags.None, - }; - IRuntimeRemotePlacement? remote = record.RemoteMotion as IRuntimeRemotePlacement; if (record.FullCellId != result.CellId) @@ -1354,6 +1367,109 @@ internal sealed class RuntimeSetPositionState : IDisposable remote.LastShadowSyncOrientation = result.Orientation; } + uint committedCellId = result.CellId; + bool collidedWithEnvironment = result.CollidedWithEnvironment; + System.Collections.Immutable.ImmutableArray 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( operation.Key.LocalEntityId, result.Position, @@ -1370,39 +1486,44 @@ internal sealed class RuntimeSetPositionState : IDisposable operation.EnteringWorldFromCelllessResidence = false; CancelLostFamilyDeadlines(operation); - uint committedCellId = result.CellId; - bool IsCanonicalCommitCurrent() => - _entities.IsCurrent(record) - && ReferenceEquals(record.PhysicsBody, body) - && record.PlacementCommitVersion == canonicalCommitVersion - && record.FullCellId == committedCellId - && _physics.IsSpatialRoot(record); - Action? hitGround = remote is null ? null : remote.HitGround; - Action? leaveGround = remote is null ? null : remote.LeaveGround; - if (!PhysicsObjUpdate.CommitSetPositionTransition( - body, - result.InContact, - result.OnWalkable, - result.CollisionNormalValid, - result.CollisionNormal, - operation.PreviousContact, - operation.PreviousOnWalkable, - hitGround, - leaveGround, - IsCanonicalCommitCurrent, - () => IsCanonicalCommitCurrent() - && IsVelocityCurrent(operation))) - { - return false; - } - if (remote is not null) - remote.Airborne = !body.OnWalkable; return IsCurrent(operation) - && IsCanonicalCommitCurrent(); + && 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) + && ReferenceEquals(record.PhysicsBody, body) + && record.PositionAuthorityVersion + == operation.PositionAuthorityVersion + && record.SpatialAuthorityVersion + == operation.SpatialAuthorityVersion + && record.PlacementCommitVersion == placementCommitVersion + && record.FullCellId == fullCellId + && (!requireSpatialRoot || _physics.IsSpatialRoot(record)); + + private bool IsCollisionReportingEligible( + RuntimeEntityRecord record, + PhysicsBody body) => + _entities.IsCurrent(record) + && ReferenceEquals(record.PhysicsBody, body) + && body.InWorld + && (body.State & PhysicsStateFlags.Hidden) == 0; + private void WithdrawCanonical(RuntimeEntityRecord record) { + _physics.CollisionReports.LeaveWorld(record); _physics.RemoveSpatialProjection(record); if (record.Key is { } key) _physics.Engine.ShadowObjects.Suspend(key.LocalEntityId); diff --git a/tests/AcDream.App.Tests/Runtime/RuntimePhysicsOwnershipTests.cs b/tests/AcDream.App.Tests/Runtime/RuntimePhysicsOwnershipTests.cs index 37082f4f..5c64367d 100644 --- a/tests/AcDream.App.Tests/Runtime/RuntimePhysicsOwnershipTests.cs +++ b/tests/AcDream.App.Tests/Runtime/RuntimePhysicsOwnershipTests.cs @@ -63,6 +63,73 @@ public sealed class RuntimePhysicsOwnershipTests 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] public void AppPhysicsFilesArePresentationAndPreparedAssetAdapters() { diff --git a/tests/AcDream.Runtime.Tests/Physics/RuntimeCollisionReportingStateTests.cs b/tests/AcDream.Runtime.Tests/Physics/RuntimeCollisionReportingStateTests.cs new file mode 100644 index 00000000..90de9944 --- /dev/null +++ b/tests/AcDream.Runtime.Tests/Physics/RuntimeCollisionReportingStateTests.cs @@ -0,0 +1,1860 @@ +using System.Collections.Immutable; +using System.Numerics; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.Core.Physics; +using AcDream.Runtime.Entities; +using AcDream.Runtime.Physics; + +namespace AcDream.Runtime.Tests.Physics; + +public sealed class RuntimeCollisionReportingStateTests +{ + private const uint Landblock = 0xA9B40000u; + private const uint Cell = Landblock | 0x0001u; + + [Fact] + public void ExactObjectReportsAreOrderedDeduplicatedAndExpireAtRetailThreshold() + { + using var lifetime = Lifetime(); + RuntimeEntityRecord owner = Entity( + lifetime, + 0x70002001u, + 1, + PhysicsStateFlags.ReportCollisions); + RuntimeEntityRecord first = Entity( + lifetime, + 0x70002002u, + 1, + PhysicsStateFlags.ReportCollisions); + RuntimeEntityRecord second = Entity( + lifetime, + 0x70002003u, + 1, + PhysicsStateFlags.None); + RegisterDynamicShadow(lifetime, first); + RegisterDynamicShadow(lifetime, second); + var observer = new CollisionObserver(); + using IDisposable subscription = lifetime.Physics.CollisionReports + .Subscribe(observer); + + Assert.True(Handle( + lifetime, + owner, + time: 10d, + Collisions( + first.Key!.Value.LocalEntityId, + first.Key.Value.LocalEntityId, + second.Key!.Value.LocalEntityId))); + + Assert.Collection( + observer.Reports, + report => AssertReport( + report, + RuntimeCollisionReportKind.ObjectCollision, + owner, + first), + report => AssertReport( + report, + RuntimeCollisionReportKind.ObjectCollision, + first, + owner), + report => AssertReport( + report, + RuntimeCollisionReportKind.ObjectCollision, + owner, + second)); + RuntimeCollisionReportingOwnershipSnapshot tracked = lifetime.Physics + .CollisionReports.CaptureOwnership(); + Assert.Equal(1, tracked.OwnerCount); + Assert.Equal(2, tracked.TrackedObjectCount); + Assert.Equal(2, tracked.ReversePeerCount); + + observer.Reports.Clear(); + Assert.False(Handle(lifetime, owner, time: 11d, Collisions())); + Assert.Empty(observer.Reports); + Assert.Equal(2, lifetime.Physics.CollisionReports.CaptureOwnership() + .TrackedObjectCount); + + Assert.False(Handle(lifetime, owner, time: 11.0001d, Collisions())); + Assert.Collection( + observer.Reports, + report => AssertReport( + report, + RuntimeCollisionReportKind.ObjectCollisionEnd, + owner, + first), + report => AssertReport( + report, + RuntimeCollisionReportKind.ObjectCollisionEnd, + first, + owner), + report => AssertReport( + report, + RuntimeCollisionReportKind.ObjectCollisionEnd, + owner, + second)); + RuntimeCollisionReportingOwnershipSnapshot ended = lifetime.Physics + .CollisionReports.CaptureOwnership(); + Assert.Equal(0, ended.OwnerCount); + Assert.Equal(0, ended.TrackedObjectCount); + Assert.Equal(0, ended.ReversePeerCount); + } + + [Fact] + public void NoReportParticipantsTrackWithoutTurningPresenceIntoSuccess() + { + using var lifetime = Lifetime(); + RuntimeEntityRecord owner = Entity( + lifetime, + 0x70002010u, + 1, + PhysicsStateFlags.None); + RuntimeEntityRecord target = Entity( + lifetime, + 0x70002011u, + 1, + PhysicsStateFlags.None); + RegisterDynamicShadow(lifetime, target); + + Assert.False(Handle( + lifetime, + owner, + time: 3d, + Collisions(target.Key!.Value.LocalEntityId))); + RuntimeCollisionReportingOwnershipSnapshot ownership = lifetime.Physics + .CollisionReports.CaptureOwnership(); + Assert.Equal(1, ownership.OwnerCount); + Assert.Equal(1, ownership.TrackedObjectCount); + } + + [Fact] + public void ExactStaticAndReportAsEnvironmentObjectsUseEnvironmentLatch() + { + using var lifetime = Lifetime(); + RuntimeEntityRecord owner = Entity( + lifetime, + 0x70002020u, + 1, + PhysicsStateFlags.ReportCollisions); + uint staticId = 0x00F00001u; + RegisterShadow( + lifetime, + staticId, + PhysicsStateFlags.None, + isStatic: true); + RuntimeEntityRecord environmentObject = Entity( + lifetime, + 0x70002021u, + 1, + PhysicsStateFlags.ReportAsEnvironment); + RegisterDynamicShadow(lifetime, environmentObject); + var observer = new CollisionObserver(); + using IDisposable subscription = lifetime.Physics.CollisionReports + .Subscribe(observer); + + Assert.True(Handle( + lifetime, + owner, + time: 1d, + Collisions( + staticId, + environmentObject.Key!.Value.LocalEntityId, + environmentCollision: true))); + Assert.Single(observer.Reports); + AssertReport( + observer.Reports[0], + RuntimeCollisionReportKind.EnvironmentCollision, + owner, + other: null); + Assert.Equal(1, lifetime.Physics.CollisionReports.CaptureOwnership() + .TrackedObjectCount); + + observer.Reports.Clear(); + Assert.False(Handle( + lifetime, + owner, + time: 1.1d, + Collisions(staticId, environmentCollision: true))); + Assert.Empty(observer.Reports); + + Assert.False(Handle(lifetime, owner, time: 1.2d, Collisions())); + Assert.True(Handle( + lifetime, + owner, + time: 1.3d, + Collisions(staticId, environmentCollision: true))); + Assert.Single(observer.Reports); + } + + [Fact] + public void UnknownObjectAndNonFiniteClockFailClosedWithoutTracking() + { + using var lifetime = Lifetime(); + RuntimeEntityRecord owner = Entity( + lifetime, + 0x70002030u, + 1, + PhysicsStateFlags.ReportCollisions); + RuntimeEntityRecord target = Entity( + lifetime, + 0x70002031u, + 1, + PhysicsStateFlags.ReportCollisions); + RegisterDynamicShadow(lifetime, target); + + Assert.False(Handle( + lifetime, + owner, + time: double.NaN, + Collisions(target.Key!.Value.LocalEntityId))); + Assert.False(Handle( + lifetime, + owner, + time: 2d, + Collisions(0x00ABCDEFu))); + RuntimeCollisionReportingOwnershipSnapshot ownership = lifetime.Physics + .CollisionReports.CaptureOwnership(); + Assert.Equal(0, ownership.OwnerCount); + Assert.Equal(0, ownership.TrackedObjectCount); + } + + [Fact] + public void EtherealContactExpiresAfterItsTouchedQuantum() + { + using var lifetime = Lifetime(); + RuntimeEntityRecord owner = Entity( + lifetime, + 0x70002040u, + 1, + PhysicsStateFlags.ReportCollisions); + RuntimeEntityRecord target = Entity( + lifetime, + 0x70002041u, + 1, + PhysicsStateFlags.ReportCollisions | PhysicsStateFlags.Ethereal); + RegisterDynamicShadow(lifetime, target); + + Assert.True(Handle( + lifetime, + owner, + time: 4d, + Collisions(target.Key!.Value.LocalEntityId))); + Assert.False(Handle(lifetime, owner, time: 4d, Collisions())); + Assert.Equal(1, lifetime.Physics.CollisionReports.CaptureOwnership() + .TrackedObjectCount); + Assert.False(Handle(lifetime, owner, time: 4.0001d, Collisions())); + Assert.Equal(0, lifetime.Physics.CollisionReports.CaptureOwnership() + .TrackedObjectCount); + } + + [Fact] + public void IgnoreAsymmetryAndMissileStopFollowRetailStateReads() + { + using var lifetime = Lifetime(); + PhysicsStateFlags missile = PhysicsStateFlags.ReportCollisions + | PhysicsStateFlags.Missile + | PhysicsStateFlags.AlignPath + | PhysicsStateFlags.PathClipped; + RuntimeEntityRecord owner = Entity( + lifetime, 0x70002042u, 1, missile); + RuntimeEntityRecord ignored = Entity( + lifetime, + 0x70002043u, + 1, + PhysicsStateFlags.ReportCollisions + | PhysicsStateFlags.IgnoreCollisions); + RegisterDynamicShadow(lifetime, owner); + RegisterDynamicShadow(lifetime, ignored); + var observer = new CollisionObserver(); + using IDisposable subscription = lifetime.Physics.CollisionReports + .Subscribe(observer); + + Assert.True(Handle( + lifetime, + owner, + 4d, + Collisions(ignored.Key!.Value.LocalEntityId))); + RuntimeCollisionReport reciprocal = Assert.Single(observer.Reports); + Assert.Equal(ignored.Key, reciprocal.Recipient); + Assert.True(owner.FinalPhysicsState.HasFlag(PhysicsStateFlags.Missile)); + + RuntimeEntityRecord ordinary = Entity( + lifetime, 0x70002044u, 1, PhysicsStateFlags.None); + RegisterDynamicShadow(lifetime, ordinary); + ulong mutation = owner.PhysicsStateMutationVersion; + Assert.True(Handle( + lifetime, + owner, + 4.1d, + Collisions(ordinary.Key!.Value.LocalEntityId))); + Assert.Equal(mutation + 1UL, owner.PhysicsStateMutationVersion); + PhysicsStateFlags cleared = PhysicsStateFlags.Missile + | PhysicsStateFlags.AlignPath + | PhysicsStateFlags.PathClipped; + Assert.Equal(0u, (uint)(owner.FinalPhysicsState & cleared)); + Assert.Equal(owner.FinalPhysicsState, owner.PhysicsBody!.State); + Assert.True(lifetime.Physics.Engine.ShadowObjects + .TryGetCollisionOwner( + owner.Key!.Value.LocalEntityId, + out uint shadowState, + out _)); + Assert.Equal((uint)owner.FinalPhysicsState, shadowState); + + RuntimeEntityRecord sourceIgnoring = Entity( + lifetime, + 0x70002045u, + 1, + PhysicsStateFlags.ReportCollisions + | PhysicsStateFlags.IgnoreCollisions); + RuntimeEntityRecord reportingTarget = Entity( + lifetime, + 0x70002046u, + 1, + PhysicsStateFlags.ReportCollisions); + RegisterDynamicShadow(lifetime, reportingTarget); + observer.Reports.Clear(); + Assert.True(Handle( + lifetime, + sourceIgnoring, + 4.2d, + Collisions(reportingTarget.Key!.Value.LocalEntityId))); + RuntimeCollisionReport sourceOnly = Assert.Single(observer.Reports); + Assert.Equal(sourceIgnoring.Key, sourceOnly.Recipient); + } + + [Fact] + public void ObjectReportDoesNotClearMissileAddedBySourceCallback() + { + using var lifetime = Lifetime(); + RuntimeEntityRecord owner = Entity( + lifetime, + 0x70002093u, + 1, + PhysicsStateFlags.ReportCollisions); + RuntimeEntityRecord target = Entity( + lifetime, 0x70002094u, 1, PhysicsStateFlags.None); + RegisterDynamicShadow(lifetime, target); + bool added = false; + var observer = new CollisionObserver(report => + { + if (added + || report.Kind is not RuntimeCollisionReportKind.ObjectCollision + || report.Recipient != owner.Key) + { + return; + } + PhysicsStateFlags state = PhysicsStateFlags.ReportCollisions + | PhysicsStateFlags.Missile + | PhysicsStateFlags.AlignPath + | PhysicsStateFlags.PathClipped; + added = lifetime.TryApplyState( + new SetState.Parsed( + owner.ServerGuid, + (uint)state, + owner.Incarnation, + StateSequence: 2), + acknowledgeProjection: null, + out _, + out _); + }); + using IDisposable subscription = lifetime.Physics.CollisionReports + .Subscribe(observer); + + Assert.True(Handle( + lifetime, + owner, + 4d, + Collisions(target.Key!.Value.LocalEntityId))); + + Assert.True(added); + PhysicsStateFlags retained = owner.FinalPhysicsState; + Assert.NotEqual(0u, (uint)(retained & PhysicsStateFlags.Missile)); + Assert.NotEqual(0u, (uint)(retained & PhysicsStateFlags.AlignPath)); + Assert.NotEqual(0u, (uint)(retained & PhysicsStateFlags.PathClipped)); + } + + [Fact] + public void PreCallbackMissileGateClearsCurrentPathBitsAfterCallback() + { + using var lifetime = Lifetime(); + PhysicsStateFlags initial = PhysicsStateFlags.ReportCollisions + | PhysicsStateFlags.Missile + | PhysicsStateFlags.AlignPath + | PhysicsStateFlags.PathClipped; + RuntimeEntityRecord owner = Entity( + lifetime, 0x70002095u, 1, initial); + RuntimeEntityRecord target = Entity( + lifetime, 0x70002096u, 1, PhysicsStateFlags.None); + RegisterDynamicShadow(lifetime, owner); + RegisterDynamicShadow(lifetime, target); + bool changed = false; + var observer = new CollisionObserver(report => + { + if (changed + || report.Kind is not RuntimeCollisionReportKind.ObjectCollision + || report.Recipient != owner.Key) + { + return; + } + PhysicsStateFlags callbackState = + PhysicsStateFlags.ReportCollisions + | PhysicsStateFlags.AlignPath + | PhysicsStateFlags.PathClipped; + changed = lifetime.TryApplyState( + new SetState.Parsed( + owner.ServerGuid, + (uint)callbackState, + owner.Incarnation, + StateSequence: 2), + acknowledgeProjection: null, + out _, + out _); + }); + using IDisposable subscription = lifetime.Physics.CollisionReports + .Subscribe(observer); + + Assert.True(Handle( + lifetime, + owner, + 4d, + Collisions(target.Key!.Value.LocalEntityId))); + + Assert.True(changed); + const PhysicsStateFlags stopped = PhysicsStateFlags.Missile + | PhysicsStateFlags.AlignPath + | PhysicsStateFlags.PathClipped; + Assert.Equal(0u, (uint)(owner.FinalPhysicsState & stopped)); + Assert.Equal(owner.FinalPhysicsState, owner.PhysicsBody!.State); + Assert.True(lifetime.Physics.Engine.ShadowObjects + .TryGetCollisionOwner( + owner.Key!.Value.LocalEntityId, + out uint shadowState, + out _)); + Assert.Equal((uint)owner.FinalPhysicsState, shadowState); + } + + [Fact] + public void CanonicalDynamicStateOverridesStaleShadowClassification() + { + using var lifetime = Lifetime(); + PhysicsStateFlags missile = PhysicsStateFlags.ReportCollisions + | PhysicsStateFlags.Missile + | PhysicsStateFlags.AlignPath + | PhysicsStateFlags.PathClipped; + RuntimeEntityRecord owner = Entity( + lifetime, 0x7000208Bu, 1, missile); + RuntimeEntityRecord ignored = Entity( + lifetime, 0x7000208Cu, 1, PhysicsStateFlags.None); + RegisterDynamicShadow(lifetime, ignored); + ignored.PhysicsBody!.State = PhysicsStateFlags.IgnoreCollisions; + lifetime.Entities.SetFinalPhysicsState( + ignored, + PhysicsStateFlags.IgnoreCollisions); + var observer = new CollisionObserver(); + using IDisposable subscription = lifetime.Physics.CollisionReports + .Subscribe(observer); + + Assert.False(Handle( + lifetime, + owner, + 4d, + Collisions(ignored.Key!.Value.LocalEntityId))); + Assert.Empty(observer.Reports); + Assert.True(owner.FinalPhysicsState.HasFlag(PhysicsStateFlags.Missile)); + + RuntimeEntityRecord environmentOwner = Entity( + lifetime, + 0x7000208Du, + 1, + PhysicsStateFlags.ReportCollisions); + RuntimeEntityRecord newlyEnvironment = Entity( + lifetime, 0x7000208Eu, 1, PhysicsStateFlags.None); + RegisterDynamicShadow(lifetime, newlyEnvironment); + newlyEnvironment.PhysicsBody!.State = + PhysicsStateFlags.ReportAsEnvironment; + lifetime.Entities.SetFinalPhysicsState( + newlyEnvironment, + PhysicsStateFlags.ReportAsEnvironment); + observer.Reports.Clear(); + Assert.True(Handle( + lifetime, + environmentOwner, + 4.1d, + Collisions(newlyEnvironment.Key!.Value.LocalEntityId))); + Assert.Equal( + RuntimeCollisionReportKind.EnvironmentCollision, + Assert.Single(observer.Reports).Kind); + + RuntimeEntityRecord ordinaryOwner = Entity( + lifetime, + 0x7000208Fu, + 1, + PhysicsStateFlags.ReportCollisions); + RuntimeEntityRecord noLongerEnvironment = Entity( + lifetime, + 0x70002090u, + 1, + PhysicsStateFlags.Static + | PhysicsStateFlags.ReportAsEnvironment); + RegisterDynamicShadow(lifetime, noLongerEnvironment); + noLongerEnvironment.PhysicsBody!.State = PhysicsStateFlags.None; + lifetime.Entities.SetFinalPhysicsState( + noLongerEnvironment, + PhysicsStateFlags.None); + observer.Reports.Clear(); + Assert.True(Handle( + lifetime, + ordinaryOwner, + 4.2d, + Collisions(noLongerEnvironment.Key!.Value.LocalEntityId))); + Assert.Equal( + RuntimeCollisionReportKind.ObjectCollision, + Assert.Single(observer.Reports).Kind); + + RuntimeEntityRecord shadowOnlyOwner = Entity( + lifetime, + 0x70002091u, + 1, + PhysicsStateFlags.ReportCollisions); + const uint shadowOnlyId = 0x00F02091u; + RegisterShadow( + lifetime, + shadowOnlyId, + PhysicsStateFlags.ReportAsEnvironment, + isStatic: false); + observer.Reports.Clear(); + Assert.False(Handle( + lifetime, + shadowOnlyOwner, + 4.3d, + Collisions(shadowOnlyId))); + Assert.Empty(observer.Reports); + } + + [Fact] + public void RegisteredStaticAndUnobservedEnvironmentUseExactLatch() + { + using var lifetime = Lifetime(); + RuntimeEntityRecord owner = Entity( + lifetime, + 0x70002047u, + 1, + PhysicsStateFlags.ReportCollisions); + const uint rawStaticId = 0x00F00047u; + RegisterShadow( + lifetime, + rawStaticId, + PhysicsStateFlags.Static, + isStatic: true); + var observer = new CollisionObserver(); + using IDisposable subscription = lifetime.Physics.CollisionReports + .Subscribe(observer); + + Assert.True(Handle( + lifetime, + owner, + 1d, + Collisions(rawStaticId))); + Assert.Equal( + RuntimeCollisionReportKind.EnvironmentCollision, + Assert.Single(observer.Reports).Kind); + + RuntimeEntityRecord unobserved = Entity( + lifetime, 0x70002092u, 1, PhysicsStateFlags.None); + Assert.False(Handle( + lifetime, + unobserved, + 1.1d, + Collisions([], environmentCollision: true))); + Assert.Equal(1, lifetime.Physics.CollisionReports.CaptureOwnership() + .OwnerCount); + Assert.False(Handle( + lifetime, + unobserved, + 1.2d, + Collisions([], environmentCollision: true))); + Assert.False(Handle(lifetime, unobserved, 1.3d, Collisions())); + Assert.Equal(0, lifetime.Physics.CollisionReports.CaptureOwnership() + .OwnerCount); + } + + [Fact] + public void ReportAsEnvironmentSuppressesBothObjectEndCallbacks() + { + using var lifetime = Lifetime(); + RuntimeEntityRecord owner = Entity( + lifetime, + 0x70002048u, + 1, + PhysicsStateFlags.ReportCollisions); + RuntimeEntityRecord target = Entity( + lifetime, + 0x70002049u, + 1, + PhysicsStateFlags.ReportCollisions + | PhysicsStateFlags.ReportAsEnvironment); + RegisterDynamicShadow(lifetime, target); + var observer = new CollisionObserver(); + using IDisposable subscription = lifetime.Physics.CollisionReports + .Subscribe(observer); + + Assert.True(Handle( + lifetime, + owner, + 1d, + Collisions(target.Key!.Value.LocalEntityId))); + observer.Reports.Clear(); + Assert.False(Handle(lifetime, owner, 2.0001d, Collisions())); + Assert.Empty(observer.Reports); + Assert.Equal(0, lifetime.Physics.CollisionReports.CaptureOwnership() + .TrackedObjectCount); + } + + [Fact] + public void NestedReportsDrainFifoAndObserverFailureDoesNotCorruptQueue() + { + using var lifetime = Lifetime(); + RuntimeEntityRecord firstOwner = Entity( + lifetime, + 0x7000204Au, + 1, + PhysicsStateFlags.ReportCollisions); + RuntimeEntityRecord firstTarget = Entity( + lifetime, 0x7000204Bu, 1, PhysicsStateFlags.None); + RuntimeEntityRecord secondOwner = Entity( + lifetime, + 0x7000204Cu, + 1, + PhysicsStateFlags.ReportCollisions); + RuntimeEntityRecord secondTarget = Entity( + lifetime, 0x7000204Du, 1, PhysicsStateFlags.None); + RegisterDynamicShadow(lifetime, firstTarget); + RegisterDynamicShadow(lifetime, secondTarget); + bool nested = false; + var firstObserver = new CollisionObserver(_ => + { + if (nested) + return; + nested = true; + Assert.True(Handle( + lifetime, + secondOwner, + 2d, + Collisions(secondTarget.Key!.Value.LocalEntityId))); + }); + var secondObserver = new CollisionObserver(); + var failing = new ThrowingCollisionObserver(); + using IDisposable firstSubscription = lifetime.Physics.CollisionReports + .Subscribe(firstObserver); + using IDisposable failingSubscription = lifetime.Physics.CollisionReports + .Subscribe(failing); + using IDisposable secondSubscription = lifetime.Physics.CollisionReports + .Subscribe(secondObserver); + Assert.Throws(() => + lifetime.Physics.CollisionReports.Subscribe(secondObserver)); + + Assert.True(Handle( + lifetime, + firstOwner, + 1d, + Collisions(firstTarget.Key!.Value.LocalEntityId))); + Assert.Collection( + secondObserver.Reports, + report => Assert.Equal(firstOwner.Key, report.Recipient), + report => Assert.Equal(secondOwner.Key, report.Recipient)); + RuntimeCollisionReportingOwnershipSnapshot ownership = lifetime.Physics + .CollisionReports.CaptureOwnership(); + Assert.Equal(2, ownership.DispatchFailureCount); + Assert.Equal(0, ownership.PendingReportCount); + Assert.False(ownership.IsDispatching); + } + + [Fact] + public void ReentrantResetDropsRemainingPriorEpochReports() + { + using var lifetime = Lifetime(); + RuntimeEntityRecord owner = Entity( + lifetime, + 0x7000204Eu, + 1, + PhysicsStateFlags.ReportCollisions); + RuntimeEntityRecord first = Entity( + lifetime, 0x7000204Fu, 1, PhysicsStateFlags.None); + RuntimeEntityRecord second = Entity( + lifetime, 0x70002054u, 1, PhysicsStateFlags.None); + RegisterDynamicShadow(lifetime, first); + RegisterDynamicShadow(lifetime, second); + Assert.True(Handle( + lifetime, + owner, + 1d, + Collisions( + first.Key!.Value.LocalEntityId, + second.Key!.Value.LocalEntityId))); + bool reset = false; + var observer = new CollisionObserver(_ => + { + if (reset) + return; + reset = true; + lifetime.Physics.CollisionReports.ResetSession(); + }); + using IDisposable subscription = lifetime.Physics.CollisionReports + .Subscribe(observer); + + lifetime.Physics.CollisionReports.LeaveWorld(owner); + + Assert.True(reset); + Assert.Single(observer.Reports); + RuntimeCollisionReportingOwnershipSnapshot ownership = lifetime.Physics + .CollisionReports.CaptureOwnership(); + Assert.Equal(0, ownership.OwnerCount); + Assert.Equal(0, ownership.PendingReportCount); + Assert.False(ownership.IsDispatching); + } + + [Fact] + public void ReentrantTargetDeletionCannotDeliverSecondOrRetainStaleContact() + { + using var lifetime = Lifetime(); + RuntimeEntityRecord owner = Entity( + lifetime, + 0x70002050u, + 1, + PhysicsStateFlags.ReportCollisions); + RuntimeEntityRecord target = Entity( + lifetime, + 0x70002051u, + 1, + PhysicsStateFlags.ReportCollisions); + RegisterDynamicShadow(lifetime, target); + bool deleted = false; + var observer = new CollisionObserver(report => + { + if (deleted + || report.Kind is not RuntimeCollisionReportKind.ObjectCollision + || report.Recipient != owner.Key) + { + return; + } + deleted = true; + Assert.True(lifetime.TryAcceptDelete( + new DeleteObject.Parsed(target.ServerGuid, target.Incarnation), + isLocalPlayer: false, + removeRetainedObject: false, + out RuntimeEntityDeleteAcceptance acceptance)); + lifetime.CompleteAcceptedDelete(acceptance); + Assert.Null(lifetime.RetireCanonicalOnly(target)); + }); + using IDisposable subscription = lifetime.Physics.CollisionReports + .Subscribe(observer); + + Assert.True(Handle( + lifetime, + owner, + time: 5d, + Collisions(target.Key!.Value.LocalEntityId))); + Assert.True(deleted); + Assert.Single(observer.Reports); + RuntimeCollisionReportingOwnershipSnapshot ownership = lifetime.Physics + .CollisionReports.CaptureOwnership(); + Assert.Equal(1, ownership.OwnerCount); + Assert.Equal(1, ownership.TrackedObjectCount); + Assert.Equal(1, ownership.ReversePeerCount); + + Assert.False(Handle(lifetime, owner, time: 6.0001d, Collisions())); + Assert.Equal(2, observer.Reports.Count); + RuntimeCollisionReport ended = observer.Reports[1]; + Assert.Equal(RuntimeCollisionReportKind.ObjectCollisionEnd, ended.Kind); + Assert.Equal(target.ServerGuid, ended.OtherServerGuid); + Assert.Equal(0, lifetime.Physics.CollisionReports.CaptureOwnership() + .TrackedObjectCount); + } + + [Fact] + public void DeleteAndGuidReuseNeverTransferTrackedIncarnation() + { + using var lifetime = Lifetime(); + RuntimeEntityRecord owner = Entity( + lifetime, + 0x70002060u, + 1, + PhysicsStateFlags.ReportCollisions); + const uint reusedGuid = 0x70002061u; + RuntimeEntityRecord retired = Entity( + lifetime, + reusedGuid, + 1, + PhysicsStateFlags.ReportCollisions); + RuntimeEntityKey retiredKey = retired.Key!.Value; + RegisterDynamicShadow(lifetime, retired); + Assert.True(Handle( + lifetime, + owner, + time: 6d, + Collisions(retired.Key!.Value.LocalEntityId))); + + Assert.True(lifetime.TryAcceptDelete( + new DeleteObject.Parsed(reusedGuid, 1), + isLocalPlayer: false, + removeRetainedObject: false, + out RuntimeEntityDeleteAcceptance acceptance)); + lifetime.CompleteAcceptedDelete(acceptance); + Assert.Null(lifetime.RetireCanonicalOnly(retired)); + Assert.Equal(1, lifetime.Physics.CollisionReports.CaptureOwnership() + .TrackedObjectCount); + + RuntimeEntityRecord replacement = Entity( + lifetime, + reusedGuid, + 2, + PhysicsStateFlags.ReportCollisions); + RegisterDynamicShadow(lifetime, replacement); + Assert.NotEqual(retiredKey, replacement.Key); + Assert.True(Handle( + lifetime, + owner, + time: 6.1d, + Collisions(replacement.Key!.Value.LocalEntityId))); + Assert.Equal(2, lifetime.Physics.CollisionReports.CaptureOwnership() + .TrackedObjectCount); + + Assert.False(Handle( + lifetime, + owner, + time: 7.0001d, + Collisions(replacement.Key.Value.LocalEntityId))); + Assert.Equal(1, lifetime.Physics.CollisionReports.CaptureOwnership() + .TrackedObjectCount); + } + + [Fact] + public void HiddenLeaveWorldRejectsReentrantContactReaddition() + { + using var lifetime = Lifetime(); + RuntimeEntityRecord owner = Entity( + lifetime, + 0x70002068u, + 1, + PhysicsStateFlags.ReportCollisions); + RuntimeEntityRecord target = Entity( + lifetime, 0x70002069u, 1, PhysicsStateFlags.None); + RegisterDynamicShadow(lifetime, target); + Assert.True(Handle( + lifetime, + owner, + 1d, + Collisions(target.Key!.Value.LocalEntityId))); + bool readdResult = true; + var observer = new CollisionObserver(report => + { + if (report.Kind is RuntimeCollisionReportKind.ObjectCollisionEnd) + { + readdResult = Handle( + lifetime, + owner, + 1.1d, + Collisions(target.Key!.Value.LocalEntityId)); + } + }); + using IDisposable subscription = lifetime.Physics.CollisionReports + .Subscribe(observer); + + Assert.True(lifetime.TryApplyState( + new SetState.Parsed( + owner.ServerGuid, + (uint)(PhysicsStateFlags.ReportCollisions + | PhysicsStateFlags.Hidden), + owner.Incarnation, + StateSequence: 2), + acknowledgeProjection: null, + out _, + out RetailPhysicsStateTransition transition)); + Assert.Equal( + RetailHiddenTransition.BecameHidden, + transition.HiddenTransition); + Assert.False(readdResult); + RuntimeCollisionReportingOwnershipSnapshot ownership = lifetime.Physics + .CollisionReports.CaptureOwnership(); + Assert.Equal(0, ownership.TrackedObjectCount); + Assert.Equal(0, ownership.LeavingOwnerCount); + } + + [Fact] + public void FirstForceEndCallbackDeletingOwnerStopsRemainingSourceSuffix() + { + using var lifetime = Lifetime(); + RuntimeEntityRecord owner = Entity( + lifetime, + 0x70002082u, + 1, + PhysicsStateFlags.ReportCollisions); + RuntimeEntityRecord first = Entity( + lifetime, 0x70002083u, 1, PhysicsStateFlags.None); + RuntimeEntityRecord second = Entity( + lifetime, 0x70002084u, 1, PhysicsStateFlags.None); + RegisterDynamicShadow(lifetime, first); + RegisterDynamicShadow(lifetime, second); + Assert.True(Handle( + lifetime, + owner, + 1d, + Collisions( + first.Key!.Value.LocalEntityId, + second.Key!.Value.LocalEntityId))); + bool accepted = false; + var observer = new CollisionObserver(report => + { + if (accepted + || report.Kind is not RuntimeCollisionReportKind + .ObjectCollisionEnd + || report.Recipient != owner.Key) + { + return; + } + accepted = true; + Assert.True(lifetime.TryAcceptDelete( + new DeleteObject.Parsed(owner.ServerGuid, owner.Incarnation), + isLocalPlayer: false, + removeRetainedObject: false, + out _)); + }); + using IDisposable subscription = lifetime.Physics.CollisionReports + .Subscribe(observer); + + lifetime.Physics.CollisionReports.LeaveWorld(owner); + + Assert.True(accepted); + RuntimeCollisionReport report = Assert.Single(observer.Reports); + Assert.Equal(first.Key, report.Other); + Assert.Equal(0, lifetime.Physics.CollisionReports.CaptureOwnership() + .TrackedObjectCount); + } + + [Fact] + public void SessionClearForceEndsBeforeTerminalCollisionReset() + { + var lifetime = Lifetime(); + RuntimeEntityRecord owner = Entity( + lifetime, + 0x7000206Au, + 1, + PhysicsStateFlags.ReportCollisions); + RuntimeEntityRecord target = Entity( + lifetime, + 0x7000206Bu, + 1, + PhysicsStateFlags.ReportCollisions); + RegisterDynamicShadow(lifetime, target); + var observer = new CollisionObserver(); + using IDisposable subscription = lifetime.Physics.CollisionReports + .Subscribe(observer); + Assert.True(Handle( + lifetime, + owner, + 1d, + Collisions(target.Key!.Value.LocalEntityId))); + observer.Reports.Clear(); + + IReadOnlyList retiring = lifetime.BeginSessionClear(); + Assert.Collection( + observer.Reports, + report => Assert.Equal( + RuntimeCollisionReportKind.ObjectCollisionEnd, + report.Kind), + report => Assert.Equal( + RuntimeCollisionReportKind.ObjectCollisionEnd, + report.Kind)); + Assert.Equal(0, lifetime.Physics.CollisionReports.CaptureOwnership() + .TrackedObjectCount); + foreach (RuntimeEntityRecord record in retiring) + lifetime.CompleteSessionEntityRetirement(record); + Assert.True(lifetime.CompleteSessionClearIfConverged()); + lifetime.Dispose(); + } + + [Fact] + public void SessionClearBlocksCrossOwnerReadditionForWholeBatch() + { + var lifetime = Lifetime(); + RuntimeEntityRecord firstOwner = Entity( + lifetime, + 0x7000207Au, + 1, + PhysicsStateFlags.ReportCollisions); + RuntimeEntityRecord peer = Entity( + lifetime, 0x7000207Bu, 1, PhysicsStateFlags.None); + RuntimeEntityRecord laterOwner = Entity( + lifetime, + 0x7000207Cu, + 1, + PhysicsStateFlags.ReportCollisions); + RegisterDynamicShadow(lifetime, peer); + Assert.True(Handle( + lifetime, + firstOwner, + 1d, + Collisions(peer.Key!.Value.LocalEntityId))); + Assert.True(Handle( + lifetime, + laterOwner, + 1d, + Collisions(peer.Key.Value.LocalEntityId))); + bool attempted = false; + bool readded = true; + var observer = new CollisionObserver(report => + { + if (attempted || report.Recipient != laterOwner.Key) + return; + attempted = true; + readded = Handle( + lifetime, + firstOwner, + 1.1d, + Collisions(peer.Key.Value.LocalEntityId)); + }); + using IDisposable subscription = lifetime.Physics.CollisionReports + .Subscribe(observer); + + IReadOnlyList retiring = lifetime.BeginSessionClear(); + + Assert.True(attempted); + Assert.False(readded); + RuntimeCollisionReportingOwnershipSnapshot ownership = lifetime.Physics + .CollisionReports.CaptureOwnership(); + Assert.Equal(0, ownership.OwnerCount); + Assert.Equal(0, ownership.AdmissionBlockedOwnerCount); + foreach (RuntimeEntityRecord record in retiring) + lifetime.CompleteSessionEntityRetirement(record); + Assert.True(lifetime.CompleteSessionClearIfConverged()); + lifetime.Dispose(); + } + + [Fact] + public void SessionClearCallbackDeletingLaterBlockedOwnerStillForceEndsIt() + { + var lifetime = Lifetime(); + RuntimeEntityRecord firstOwner = Entity( + lifetime, + 0x70002085u, + 1, + PhysicsStateFlags.ReportCollisions); + RuntimeEntityRecord firstPeer = Entity( + lifetime, 0x70002086u, 1, PhysicsStateFlags.None); + RuntimeEntityRecord laterOwner = Entity( + lifetime, + 0x70002087u, + 1, + PhysicsStateFlags.ReportCollisions); + RuntimeEntityRecord laterPeer = Entity( + lifetime, 0x70002088u, 1, PhysicsStateFlags.None); + RegisterDynamicShadow(lifetime, firstPeer); + RegisterDynamicShadow(lifetime, laterPeer); + Assert.True(Handle( + lifetime, + firstOwner, + 1d, + Collisions(firstPeer.Key!.Value.LocalEntityId))); + Assert.True(Handle( + lifetime, + laterOwner, + 1d, + Collisions(laterPeer.Key!.Value.LocalEntityId))); + bool accepted = false; + var observer = new CollisionObserver(report => + { + if (accepted + || report.Kind is not RuntimeCollisionReportKind + .ObjectCollisionEnd + || report.Recipient != firstOwner.Key) + { + return; + } + accepted = true; + Assert.True(lifetime.TryAcceptDelete( + new DeleteObject.Parsed( + laterOwner.ServerGuid, + laterOwner.Incarnation), + isLocalPlayer: false, + removeRetainedObject: false, + out _)); + }); + using IDisposable subscription = lifetime.Physics.CollisionReports + .Subscribe(observer); + + IReadOnlyList retiring = lifetime.BeginSessionClear(); + + Assert.True(accepted); + Assert.Contains( + observer.Reports, + report => report.Kind + is RuntimeCollisionReportKind.ObjectCollisionEnd + && report.Recipient == laterOwner.Key + && report.Other == laterPeer.Key); + RuntimeCollisionReportingOwnershipSnapshot ownership = lifetime.Physics + .CollisionReports.CaptureOwnership(); + Assert.Equal(0, ownership.OwnerCount); + Assert.Equal(0, ownership.AdmissionBlockedOwnerCount); + foreach (RuntimeEntityRecord record in retiring) + lifetime.CompleteSessionEntityRetirement(record); + Assert.True(lifetime.CompleteSessionClearIfConverged()); + lifetime.Dispose(); + } + + [Fact] + public void InvalidSetPositionMapsNewReportThenRepeatToRetailErrors() + { + using var lifetime = Lifetime(); + RuntimeEntityRecord owner = Entity( + lifetime, + 0x7000206Cu, + 1, + PhysicsStateFlags.ReportCollisions); + RuntimeEntityRecord target = Entity( + lifetime, 0x7000206Du, 1, PhysicsStateFlags.None); + RegisterDynamicShadow(lifetime, target); + uint targetId = target.Key!.Value.LocalEntityId; + lifetime.Physics.Engine.TransitionCellCollisionTestHook = + (transition, phase, _, _) => + { + if (phase is TransitionCellCollisionPhase.Environment) + { + transition.CollisionInfo.CollideObjectGuids.Add(targetId); + transition.CollisionInfo.LastCollidedObjectGuid = targetId; + } + return TransitionState.Collided; + }; + + RuntimeSetPositionCommand command = PlacementCommand( + owner, + new Vector3(13f, 12f, 7f)); + RuntimeSetPositionOutcome first = lifetime.Physics.SetPosition.Apply( + owner, + owner.PositionAuthorityVersion, + command); + Assert.Equal(RuntimeSetPositionStatus.Rejected, first.Status); + Assert.Equal(PhysicsSetPositionError.Collided, first.Error); + Assert.True(lifetime.Physics.SetPosition.TryGetAwaitingPreparationToken( + owner, + out RuntimeEntityPlacementToken retry)); + + RuntimeSetPositionOutcome repeated = lifetime.Physics.SetPosition + .SubmitPreparedPlacement(retry, command); + Assert.Equal(RuntimeSetPositionStatus.Rejected, repeated.Status); + Assert.Equal(PhysicsSetPositionError.NoValidPosition, repeated.Error); + } + + [Fact] + public void SuccessfulSetPositionReportsBeforeResponseAndShadowReflood() + { + using var lifetime = Lifetime(); + RuntimeEntityRecord owner = Entity( + lifetime, + 0x7000206Eu, + 1, + PhysicsStateFlags.ReportCollisions); + RuntimeEntityRecord target = Entity( + lifetime, 0x7000206Fu, 1, PhysicsStateFlags.None); + RegisterDynamicShadow(lifetime, owner); + RegisterDynamicShadow(lifetime, target); + uint targetId = target.Key!.Value.LocalEntityId; + Vector3 initial = owner.PhysicsBody!.Position; + Vector3 destination = new(14f, 12f, 7f); + owner.PhysicsBody.set_velocity(-Vector3.UnitX); + owner.PhysicsBody.FramesStationaryFall = 2; + owner.PhysicsBody.TransientState |= + TransientStateFlags.StationaryStop; + lifetime.Physics.Engine.TransitionCellCollisionTestHook = + (transition, phase, _, _) => + { + if (phase is TransitionCellCollisionPhase.Environment) + { + transition.CollisionInfo.CollideObjectGuids.Add(targetId); + transition.CollisionInfo.LastCollidedObjectGuid = targetId; + transition.CollisionInfo.SetCollisionNormal(Vector3.UnitX); + transition.CollisionInfo.FramesStationaryFall = 1; + } + return TransitionState.OK; + }; + bool observed = false; + var observer = new CollisionObserver(report => + { + if (report.Kind is not RuntimeCollisionReportKind.ObjectCollision + || report.Recipient != owner.Key) + { + return; + } + observed = true; + Assert.Equal(destination, owner.PhysicsBody.Position); + Assert.Equal(-Vector3.UnitX, owner.PhysicsBody.Velocity); + Assert.Equal(2, owner.PhysicsBody.FramesStationaryFall); + Assert.NotEqual( + 0u, + (uint)(owner.PhysicsBody.TransientState + & TransientStateFlags.StationaryStop)); + Assert.Equal( + 0u, + (uint)(owner.PhysicsBody.TransientState + & TransientStateFlags.StationaryFall)); + ShadowEntry shadow = Assert.Single( + lifetime.Physics.Engine.ShadowObjects.AllEntriesForDebug(), + entry => entry.EntityId == owner.Key!.Value.LocalEntityId); + Assert.Equal(initial, shadow.Position); + }); + using IDisposable subscription = lifetime.Physics.CollisionReports + .Subscribe(observer); + + RuntimeSetPositionOutcome outcome = lifetime.Physics.SetPosition.Apply( + owner, + owner.PositionAuthorityVersion, + PlacementCommand(owner, destination)); + + Assert.Equal( + RuntimeSetPositionStatus.CommittedHostAcknowledgementPending, + outcome.Status); + Assert.True(observed); + ShadowEntry committedShadow = Assert.Single( + lifetime.Physics.Engine.ShadowObjects.AllEntriesForDebug(), + entry => entry.EntityId == owner.Key!.Value.LocalEntityId); + Assert.Equal(destination, committedShadow.Position); + Assert.True(owner.PhysicsBody.Velocity.X >= 0f); + Assert.Equal(1, owner.PhysicsBody.FramesStationaryFall); + Assert.NotEqual( + 0u, + (uint)(owner.PhysicsBody.TransientState + & TransientStateFlags.StationaryFall)); + Assert.Equal( + 0u, + (uint)(owner.PhysicsBody.TransientState + & TransientStateFlags.StationaryStop)); + } + + [Fact] + public void ReportSurvivesNewVectorWhileStalePhysicalResponseDoesNot() + { + using var lifetime = Lifetime(); + RuntimeEntityRecord owner = Entity( + lifetime, + 0x70002072u, + 1, + PhysicsStateFlags.ReportCollisions); + RuntimeEntityRecord target = Entity( + lifetime, 0x70002073u, 1, PhysicsStateFlags.None); + RegisterDynamicShadow(lifetime, owner); + RegisterDynamicShadow(lifetime, target); + uint targetId = target.Key!.Value.LocalEntityId; + lifetime.Physics.Engine.TransitionCellCollisionTestHook = + (transition, phase, _, _) => + { + if (phase is TransitionCellCollisionPhase.Environment) + { + transition.CollisionInfo.CollideObjectGuids.Add(targetId); + transition.CollisionInfo.SetCollisionNormal(Vector3.UnitX); + } + return TransitionState.OK; + }; + bool observed = false; + var observer = new CollisionObserver(report => + { + if (observed || report.Recipient != owner.Key) + return; + observed = true; + lifetime.Entities.AdvanceVectorAuthority(owner); + owner.PhysicsBody!.set_velocity(new Vector3(-7f, 3f, 0f)); + }); + using IDisposable subscription = lifetime.Physics.CollisionReports + .Subscribe(observer); + RuntimeSetPositionCommand command = PlacementCommand( + owner, + new Vector3(15f, 12f, 7f)) with + { + ExpectedVelocityAuthorityVersion = owner.VelocityAuthorityVersion, + }; + + RuntimeSetPositionOutcome outcome = lifetime.Physics.SetPosition.Apply( + owner, + owner.PositionAuthorityVersion, + command); + + Assert.True(observed); + Assert.Equal( + RuntimeSetPositionStatus.CommittedHostAcknowledgementPending, + outcome.Status); + Assert.Equal(new Vector3(-7f, 3f, 0f), owner.PhysicsBody!.Velocity); + } + + [Fact] + public void HitGroundHiddenTransitionCannotResumeCollisionTracking() + { + using var lifetime = Lifetime(); + RuntimeEntityRecord owner = Entity( + lifetime, + 0x70002074u, + 1, + PhysicsStateFlags.ReportCollisions); + RuntimeEntityRecord target = Entity( + lifetime, 0x70002075u, 1, PhysicsStateFlags.None); + RegisterDynamicShadow(lifetime, owner); + RegisterDynamicShadow(lifetime, target); + uint targetId = target.Key!.Value.LocalEntityId; + lifetime.Physics.Engine.TransitionCellCollisionTestHook = + (transition, phase, _, _) => + { + if (phase is TransitionCellCollisionPhase.Environment) + { + transition.CollisionInfo.SetContactPlane( + new Plane(Vector3.UnitZ, 0f), + Cell); + transition.CollisionInfo.CollideObjectGuids.Add(targetId); + } + return TransitionState.OK; + }; + bool hidden = false; + var remote = new ReentrantRemotePlacement(owner.PhysicsBody!) + { + CellId = Cell, + OnHitGround = () => + { + hidden = lifetime.TryApplyState( + new SetState.Parsed( + owner.ServerGuid, + (uint)(PhysicsStateFlags.ReportCollisions + | PhysicsStateFlags.Hidden), + owner.Incarnation, + StateSequence: 2), + acknowledgeProjection: null, + out _, + out _); + }, + }; + lifetime.Entities.SetRemoteMotion(owner, remote); + var observer = new CollisionObserver(); + using IDisposable subscription = lifetime.Physics.CollisionReports + .Subscribe(observer); + + RuntimeSetPositionOutcome outcome = lifetime.Physics.SetPosition.Apply( + owner, + owner.PositionAuthorityVersion, + PlacementCommand(owner, new Vector3(16f, 12f, 7f))); + + Assert.True(hidden); + Assert.Equal(RuntimeSetPositionStatus.Cancelled, outcome.Status); + Assert.True(owner.FinalPhysicsState.HasFlag(PhysicsStateFlags.Hidden)); + Assert.Empty(observer.Reports); + Assert.Equal(0, lifetime.Physics.CollisionReports.CaptureOwnership() + .TrackedObjectCount); + } + + [Fact] + public void HitGroundHiddenPeerCannotBecomeAStaleTrackedTarget() + { + using var lifetime = Lifetime(); + RuntimeEntityRecord owner = Entity( + lifetime, + 0x70002076u, + 1, + PhysicsStateFlags.ReportCollisions); + RuntimeEntityRecord target = Entity( + lifetime, + 0x70002077u, + 1, + PhysicsStateFlags.ReportCollisions); + RegisterDynamicShadow(lifetime, owner); + RegisterDynamicShadow(lifetime, target); + uint targetId = target.Key!.Value.LocalEntityId; + lifetime.Physics.Engine.TransitionCellCollisionTestHook = + (transition, phase, _, _) => + { + if (phase is TransitionCellCollisionPhase.Environment) + { + transition.CollisionInfo.SetContactPlane( + new Plane(Vector3.UnitZ, 0f), + Cell); + transition.CollisionInfo.CollideObjectGuids.Add(targetId); + } + return TransitionState.OK; + }; + var remote = new ReentrantRemotePlacement(owner.PhysicsBody!) + { + CellId = Cell, + OnHitGround = () => Assert.True(lifetime.TryApplyState( + new SetState.Parsed( + target.ServerGuid, + (uint)(PhysicsStateFlags.ReportCollisions + | PhysicsStateFlags.Hidden), + target.Incarnation, + StateSequence: 2), + acknowledgeProjection: null, + out _, + out _)), + }; + lifetime.Entities.SetRemoteMotion(owner, remote); + var observer = new CollisionObserver(); + using IDisposable subscription = lifetime.Physics.CollisionReports + .Subscribe(observer); + + RuntimeSetPositionOutcome outcome = lifetime.Physics.SetPosition.Apply( + owner, + owner.PositionAuthorityVersion, + PlacementCommand(owner, new Vector3(17f, 12f, 7f))); + + Assert.Equal( + RuntimeSetPositionStatus.CommittedHostAcknowledgementPending, + outcome.Status); + Assert.True(target.FinalPhysicsState.HasFlag(PhysicsStateFlags.Hidden)); + Assert.DoesNotContain( + observer.Reports, + report => report.Kind + is RuntimeCollisionReportKind.ObjectCollision); + Assert.Equal(0, lifetime.Physics.CollisionReports.CaptureOwnership() + .TrackedObjectCount); + } + + [Fact] + public void CollisionReportHiddenTransitionStopsResponseAndShadowReflood() + { + using var lifetime = Lifetime(); + RuntimeEntityRecord owner = Entity( + lifetime, + 0x70002078u, + 1, + PhysicsStateFlags.ReportCollisions); + RuntimeEntityRecord target = Entity( + lifetime, 0x70002079u, 1, PhysicsStateFlags.None); + RegisterDynamicShadow(lifetime, owner); + RegisterDynamicShadow(lifetime, target); + uint targetId = target.Key!.Value.LocalEntityId; + Vector3 initial = owner.PhysicsBody!.Position; + lifetime.Physics.Engine.TransitionCellCollisionTestHook = + (transition, phase, _, _) => + { + if (phase is TransitionCellCollisionPhase.Environment) + { + transition.CollisionInfo.CollideObjectGuids.Add(targetId); + transition.CollisionInfo.SetCollisionNormal(Vector3.UnitX); + } + return TransitionState.OK; + }; + bool hidden = false; + var observer = new CollisionObserver(report => + { + if (hidden + || report.Kind is not RuntimeCollisionReportKind.ObjectCollision) + { + return; + } + hidden = lifetime.TryApplyState( + new SetState.Parsed( + owner.ServerGuid, + (uint)(PhysicsStateFlags.ReportCollisions + | PhysicsStateFlags.Hidden), + owner.Incarnation, + StateSequence: 2), + acknowledgeProjection: null, + out _, + out _); + }); + using IDisposable subscription = lifetime.Physics.CollisionReports + .Subscribe(observer); + + RuntimeSetPositionOutcome outcome = lifetime.Physics.SetPosition.Apply( + owner, + owner.PositionAuthorityVersion, + PlacementCommand(owner, new Vector3(18f, 12f, 7f))); + + Assert.True(hidden); + Assert.Equal(RuntimeSetPositionStatus.Cancelled, outcome.Status); + Assert.True(owner.FinalPhysicsState.HasFlag(PhysicsStateFlags.Hidden)); + Assert.Equal(0, lifetime.Physics.CollisionReports.CaptureOwnership() + .TrackedObjectCount); + ShadowEntry shadow = Assert.Single( + lifetime.Physics.Engine.ShadowObjects.AllEntriesForDebug(), + entry => entry.EntityId == owner.Key!.Value.LocalEntityId); + Assert.Equal(initial, shadow.Position); + } + + [Fact] + public void SessionResetAndDisposalConvergeEveryCollisionOwner() + { + var lifetime = Lifetime(); + RuntimeEntityRecord owner = Entity( + lifetime, + 0x70002070u, + 1, + PhysicsStateFlags.ReportCollisions); + RuntimeEntityRecord target = Entity( + lifetime, + 0x70002071u, + 1, + PhysicsStateFlags.ReportCollisions); + RegisterDynamicShadow(lifetime, target); + var observer = new CollisionObserver(); + IDisposable subscription = lifetime.Physics.CollisionReports + .Subscribe(observer); + Assert.True(Handle( + lifetime, + owner, + time: 7d, + Collisions(target.Key!.Value.LocalEntityId))); + + IReadOnlyList retiring = lifetime.BeginSessionClear(); + RuntimeCollisionReportingOwnershipSnapshot cleared = lifetime.Physics + .CollisionReports.CaptureOwnership(); + Assert.Equal(0, cleared.OwnerCount); + Assert.Equal(0, cleared.TrackedObjectCount); + foreach (RuntimeEntityRecord record in retiring) + lifetime.CompleteSessionEntityRetirement(record); + Assert.True(lifetime.CompleteSessionClearIfConverged()); + subscription.Dispose(); + lifetime.Dispose(); + Assert.True(lifetime.Physics.CollisionReports.CaptureOwnership() + .IsConverged); + Assert.True(lifetime.Physics.CaptureOwnership().IsConverged); + } + + [Fact] + public void IndependentRuntimeRootsProduceDeterministicReportsAndResult() + { + static (bool Result, RuntimeCollisionReport[] Reports) Run() + { + using var lifetime = Lifetime(); + RuntimeEntityRecord owner = Entity( + lifetime, + 0x70002080u, + 1, + PhysicsStateFlags.ReportCollisions); + RuntimeEntityRecord target = Entity( + lifetime, + 0x70002081u, + 1, + PhysicsStateFlags.ReportCollisions); + RegisterDynamicShadow(lifetime, target); + var observer = new CollisionObserver(); + using IDisposable subscription = lifetime.Physics.CollisionReports + .Subscribe(observer); + bool result = Handle( + lifetime, + owner, + time: 8d, + Collisions(target.Key!.Value.LocalEntityId)); + return (result, observer.Reports.ToArray()); + } + + (bool graphicalResult, RuntimeCollisionReport[] graphical) = Run(); + (bool noWindowResult, RuntimeCollisionReport[] noWindow) = Run(); + Assert.Equal(graphicalResult, noWindowResult); + Assert.Equal(graphical, noWindow); + } + + [Fact] + public void WarmedSteadyContactRefreshDoesNotAllocate() + { + using var lifetime = Lifetime(); + RuntimeEntityRecord owner = Entity( + lifetime, + 0x70002089u, + 1, + PhysicsStateFlags.None); + RuntimeEntityRecord target = Entity( + lifetime, 0x7000208Au, 1, PhysicsStateFlags.None); + RegisterDynamicShadow(lifetime, target); + PhysicsSetPositionCollisionReport collision = + Collisions(target.Key!.Value.LocalEntityId); + Assert.False(Handle(lifetime, owner, 1d, collision)); + + _ = GC.GetAllocatedBytesForCurrentThread(); + long before = GC.GetAllocatedBytesForCurrentThread(); + for (int index = 0; index < 10_000; index++) + _ = Handle(lifetime, owner, 1.1d, collision); + long allocated = GC.GetAllocatedBytesForCurrentThread() - before; + + Assert.Equal(0, allocated); + Assert.Equal(1, lifetime.Physics.CollisionReports.CaptureOwnership() + .TrackedObjectCount); + } + + private static RuntimeEntityObjectLifetime Lifetime() + { + var engine = new PhysicsEngine + { + DataCache = new PhysicsDataCache(), + }; + engine.AddLandblock( + Landblock, + new TerrainSurface(new byte[81], new float[256]), + Array.Empty(), + Array.Empty(), + worldOffsetX: 0f, + worldOffsetY: 0f); + return new RuntimeEntityObjectLifetime(engine); + } + + private static RuntimeEntityRecord Entity( + RuntimeEntityObjectLifetime lifetime, + uint guid, + ushort incarnation, + PhysicsStateFlags state) + { + RuntimeEntityRecord record = lifetime.RegisterEntity( + Spawn(guid, incarnation, state)).Canonical!; + lifetime.Entities.SetFullCell(record, Cell, Landblock | 0xFFFFu); + lifetime.Entities.SetFinalPhysicsState(record, state); + var body = new PhysicsBody + { + Position = new Vector3(12f, 12f, 7f), + Orientation = Quaternion.Identity, + State = state, + LastUpdateTime = 1d, + TransientState = TransientStateFlags.Active, + }; + body.SnapToCell(Cell, body.Position, body.Position); + lifetime.Entities.SetPhysicsBody(record, body); + record.ObjectClock.Activate(); + lifetime.Physics.AcknowledgeSpatialProjection(record, spatial: true); + return record; + } + + private static void RegisterDynamicShadow( + RuntimeEntityObjectLifetime lifetime, + RuntimeEntityRecord record) => RegisterShadow( + lifetime, + record.Key!.Value.LocalEntityId, + record.PhysicsBody!.State, + isStatic: false); + + private static void RegisterShadow( + RuntimeEntityObjectLifetime lifetime, + uint localId, + PhysicsStateFlags state, + bool isStatic) => lifetime.Physics.Engine.ShadowObjects.Register( + localId, + gfxObjId: 0u, + new Vector3(12f, 12f, 7f), + Quaternion.Identity, + radius: 0.4f, + worldOffsetX: 0f, + worldOffsetY: 0f, + Landblock, + ShadowCollisionType.Sphere, + state: (uint)state, + seedCellId: Cell, + isStatic: isStatic); + + private static bool Handle( + RuntimeEntityObjectLifetime lifetime, + RuntimeEntityRecord owner, + double time, + in PhysicsSetPositionCollisionReport collision) => + lifetime.Physics.HandleSetPositionCollisions( + owner, + owner.PositionAuthorityVersion, + owner.SpatialAuthorityVersion, + owner.VelocityAuthorityVersion, + time, + owner.PhysicsBody!.InContact, + owner.PhysicsBody.OnWalkable, + collision); + + private static RuntimeSetPositionCommand PlacementCommand( + RuntimeEntityRecord owner, + Vector3 position) + { + var request = new PhysicsSetPositionRequest( + position, + Quaternion.Identity, + Cell, + position, + [new FlatCollisionSphere(Vector3.Zero, 0.4f)], + Scale: 1f, + StepUpHeight: 0.4f, + StepDownHeight: 0.4f, + MoverPhysicsState: owner.FinalPhysicsState, + MovingEntityId: owner.Key!.Value.LocalEntityId, + Flags: PhysicsSetPositionFlags.Placement + | PhysicsSetPositionFlags.Slide, + CurrentCellId: Cell); + return new RuntimeSetPositionCommand( + request, + RuntimeSetPositionOperationKind.RemoteAuthoritative, + GameTime: 10d, + ExpectedVelocityAuthorityVersion: + owner.VelocityAuthorityVersion, + ShadowWorldOffsetX: 0f, + ShadowWorldOffsetY: 0f); + } + + private static PhysicsSetPositionCollisionReport Collisions( + params uint[] objectIds) => Collisions( + objectIds, + environmentCollision: false); + + private static PhysicsSetPositionCollisionReport Collisions( + uint objectId, + bool environmentCollision) => Collisions( + [objectId], + environmentCollision); + + private static PhysicsSetPositionCollisionReport Collisions( + uint first, + uint second, + bool environmentCollision) => Collisions( + [first, second], + environmentCollision); + + private static PhysicsSetPositionCollisionReport Collisions( + uint[] objectIds, + bool environmentCollision) => new( + ContactPlaneValid: false, + ContactPlane: default, + ContactPlaneCellId: 0u, + ContactPlaneIsWater: false, + LastKnownContactPlaneValid: false, + LastKnownContactPlane: default, + LastKnownContactPlaneCellId: 0u, + LastKnownContactPlaneIsWater: false, + SlidingNormalValid: false, + SlidingNormal: default, + CollisionNormalValid: false, + CollisionNormal: default, + CollidedWithEnvironment: environmentCollision, + FramesStationaryFall: 0, + AdjustOffset: default, + LastCollidedObjectId: objectIds.Length == 0 ? null : objectIds[^1], + CollidedObjectIds: objectIds.ToImmutableArray()); + + private static void AssertReport( + RuntimeCollisionReport report, + RuntimeCollisionReportKind kind, + RuntimeEntityRecord recipient, + RuntimeEntityRecord? other) + { + Assert.Equal(kind, report.Kind); + Assert.Equal(recipient.Key, report.Recipient); + Assert.Equal(recipient.ServerGuid, report.RecipientServerGuid); + Assert.Equal(other?.Key, report.Other); + Assert.Equal(other?.ServerGuid, report.OtherServerGuid); + } + + private static WorldSession.EntitySpawn Spawn( + uint guid, + ushort instance, + PhysicsStateFlags state) + { + var position = new CreateObject.ServerPosition( + Cell, + 12f, + 12f, + 7f, + 1f, + 0f, + 0f, + 0f); + var timestamps = new PhysicsTimestamps( + Position: 1, + Movement: 1, + State: 1, + Vector: 1, + Teleport: 0, + ServerControlledMove: 1, + ForcePosition: 0, + ObjDesc: 1, + Instance: instance); + var physics = new PhysicsSpawnData( + RawState: (uint)state, + Position: position, + Movement: null, + AnimationFrame: null, + SetupTableId: 0x02000001u, + MotionTableId: 0x09000001u, + SoundTableId: null, + PhysicsScriptTableId: null, + Parent: null, + Children: null, + Scale: null, + Friction: null, + Elasticity: null, + Translucency: null, + Velocity: null, + Acceleration: null, + AngularVelocity: null, + DefaultScriptType: null, + DefaultScriptIntensity: null, + Timestamps: timestamps); + return new WorldSession.EntitySpawn( + guid, + position, + 0x02000001u, + Array.Empty(), + Array.Empty(), + Array.Empty(), + null, + null, + "collision-report-fixture", + null, + null, + 0x09000001u, + PhysicsState: (uint)state, + InstanceSequence: instance, + MovementSequence: 1, + ServerControlSequence: 1, + PositionSequence: 1, + Physics: physics); + } + + private sealed class CollisionObserver( + Action? onReport = null) + : IRuntimeCollisionReportObserver + { + internal List Reports { get; } = []; + + public void OnCollisionReport(in RuntimeCollisionReport report) + { + Reports.Add(report); + onReport?.Invoke(report); + } + } + + private sealed class ThrowingCollisionObserver + : IRuntimeCollisionReportObserver + { + public void OnCollisionReport(in RuntimeCollisionReport report) => + throw new InvalidOperationException( + $"fixture failure {report.Sequence}"); + } + + private sealed class ReentrantRemotePlacement(PhysicsBody body) + : IRuntimeRemotePlacement + { + private Func? _readCell; + private Action? _writeCell; + private uint _cellId; + + public PhysicsBody Body { get; } = body; + public uint CellId + { + get => _readCell?.Invoke() ?? _cellId; + set + { + _cellId = value; + _writeCell?.Invoke(value); + } + } + public bool Airborne { get; set; } + public Vector3 LastServerPosition { get; set; } + public double LastServerPositionTime { get; set; } + public Vector3 LastShadowSyncPosition { get; set; } + public Quaternion LastShadowSyncOrientation { get; set; } + internal Action? OnHitGround { get; init; } + internal Action? OnLeaveGround { get; init; } + + public void BindCanonicalCell(Func read, Action write) + { + _readCell = read; + _writeCell = write; + } + + public void HitGround() => OnHitGround?.Invoke(); + public void LeaveGround() => OnLeaveGround?.Invoke(); + } +}