From 6b28ff999cf847816fdb18b7ea3c20b4c9a58f00 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 31 Jul 2026 18:34:46 +0200 Subject: [PATCH] fix(physics): make collision activation starvation-free --- docs/architecture/acdream-architecture.md | 48 +- .../retail-divergence-register.md | 2 +- .../2026-07-31-atomic-collision-generation.md | 145 +- memory/project_collision_port.md | 37 +- .../Streaming/LandblockPhysicsPublisher.cs | 24 +- .../Physics/CollisionWorldState.cs | 86 + src/AcDream.Core/Physics/PhysicsDataCache.cs | 127 +- src/AcDream.Core/Physics/PhysicsEngine.cs | 838 ++++++- .../Physics/ShadowObjectRegistry.cs | 639 ++++-- src/AcDream.Core/World/Cells/CellGraph.cs | 104 +- .../Physics/RuntimePhysicsState.cs | 1099 +++++++++- .../LandblockPhysicsPublisherTests.cs | 4 + .../Physics/PhysicsEngineTests.cs | 12 +- .../Physics/RuntimePhysicsStateTests.cs | 1946 ++++++++++++++++- 14 files changed, 4637 insertions(+), 474 deletions(-) create mode 100644 src/AcDream.Core/Physics/CollisionWorldState.cs diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md index d1d8dac8..b46152fa 100644 --- a/docs/architecture/acdream-architecture.md +++ b/docs/architecture/acdream-architecture.md @@ -494,13 +494,47 @@ What exists and is active: target prefix (including a withdrawn repair marker); target-root statics come from the authored replacement. Runtime mutation-gates their exact capture, refreshes each through the host work meter, and builds every cache/graph/ - shadow replacement list through one-work-unit seal cursors. The final update- - thread activation performs one mutation-version check and installs the sealed - records without heap allocation before emitting - `CollisionGenerationCommitted`. Cancellation disposes only the named staging - generation and never withdraws the previous active world. Thus readers see - the complete old generation or complete new generation, never a mixed - cell/cache/shadow world. + shadow replacement through one-work-unit seal cursors. Stable per-prefix + owner slots replace the former registry-global mutation gate. One Runtime- + scoped versioned journal coalesces repeated live mutations by owner instead + of copying the owner into every draft on every event. Each draft reconciles + only the latest exact state for owners changed during its lifetime, one owner + per seal step, so unrelated or continuously moving owners cannot restart the + target cursors. Once discovered, a relevant owner receives exact subscribed + updates without restoring global fanout. First entry to or departure from a + target after the global slot cursor has passed is routed through the owner's + changed prefix to that one matching draft. During topology construction a + visited unrelated owner receives only a cheap coalesced dirty notification; + its exact mirror is deferred to one metered seal unit. Once the topology seal + exists, observed owners temporarily write through exactly until activation, + so the finite pre-seal queue drains even when several unrelated owners move + continuously. Production activates in that same update-thread call. Slots older than a + newer draft's captured root are superseded at the tail rather than reused + behind live cursors; new drafts start at their captured suffix, obsolete + slots compact incrementally, and the journal clears with the last draft. + Empty prefix containers are reclaimed under GUID churn; seal cursors retain + their captured slot lists. + Cache, CellGraph, engine, and shadow topology share one complete off-side + `CollisionWorldState`. Admission captures the current root reference in O(1) + and materializes the non-target leaves through the same one-work-unit frame + meter; a dense resident world is never cloned synchronously. After an older + preparation commits, its exact landblock delta queues into every later draft + and drains one cache, graph, landblock, or owner leaf per seal step. A later + demotion or withdrawal cancels matching queued/active rebases and tombstones + that prefix in unfinished source scans, then retires one owner/cache/graph/ + outdoor leaf per seal step from growable retirement storage. Commit rechecks + both retirement and rebase state after sealing, so retired topology cannot + return or cause a drafts-times-world-size update spike. + The host immediately performs the zero-work root transfer in the same update- + thread call that completes final reconciliation, so continuous unrelated + movement cannot manufacture a required quiet frame between seal and commit. + Deterministic preparation order prevents a later draft from exposing early, + inheriting cancelled topology, or overwriting a committed prefix. Final activation is one + zero-allocation volatile root transfer that preserves public facade identity, + revokes staging, and then emits `CollisionGenerationCommitted`. Cancellation disposes only the named + staging generation and never withdraws the previous active world. Thus + readers see the complete old generation or complete new generation, never a + mixed cell/cache/shadow world. - `ShadowObjectRegistry` gives movement a per-cell broadphase over nearby objects and buildings. Streaming reflood is structurally part of the Runtime collision-generation commit; there is no independent post-publication diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 1b19d620..8281655c 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -89,7 +89,7 @@ AD-53..AD-55 (Campaign P response-layer findings). | AD-1 | Lost-cell machinery replaced by recoverable outdoor demote (**#107** safety net) + outdoor-restore `max(terrainZ, z)` under-terrain lift; retail goes `GotoLostCell` | `src/AcDream.Core/Physics/PhysicsEngine.cs:553` (+ :808) | acdream has no lost-cell state machine; outdoor landcell is the recoverable equivalent; the #107 auto-entry hold should make the demote branch unreachable | Gap in the hold → player committed to outdoor terrain inside/under a building (fake-grounded spawn, fall-through); a legit below-heightmap server restore is silently lifted — upward warp vs server | `GotoLostCell` pc:283418; `SetPositionInternal` 0x00515bd0, pc:283892-283945 | | AD-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 off-side through mutation-stable, one-work-unit capture/seal cursors; rowless owner state/payload updates, movement, spawn, deletion, and seam-static changes reject stale sealing. The complete previous generation remains queryable until one allocation-free update-thread activation. A stale admission or staging failure disposes only that private generation and cannot withdraw the active world or invalidate a newer admission. The commit installs precomputed cell rows and 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`, `AdvanceCollisionGenerationSeal`, `CommitCollisionGeneration`); `src/AcDream.Core/Physics/PhysicsDataCache.cs`; `PhysicsEngine.cs`; `ShadowObjectRegistry.cs`; `src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs`; `src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs`; `tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsStateTests.cs`; `tests/AcDream.App.Tests/Streaming/LandblockPhysicsPublisherTests.cs`; `tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs` | — | — | `CObjCell::init_objects` → `CPhysicsObj::recalc_cross_cells`, 0x0052b420 / 0x00515a30; `CPhysicsObj::SetPositionInternal` shadow replacement tail 0x00515330 | +| ~~AD-6~~ | **RETIRED 2026-07-31 (placement/streaming Slice 3B).** Cell/cache/topology/building/static-shadow publication plus every retained non-suspended owner touching or withdrawn from the prefix is one Runtime-owned collision generation. Retained includes dynamics and adjacent-root statics; only target-root statics are superseded by the authored replacement. App and Headless build one shared off-side `CollisionWorldState` through one-work-unit preparation/capture/seal cursors. Admission captures the active root in O(1); a stable landblock/owner slot suffix materializes non-target leaves incrementally, so resident-world size cannot become a synchronous clone spike. Reusable per-prefix owner slots and one Runtime-scoped versioned journal replace event-time exact-copy fanout: repeated live mutations coalesce by owner, every draft reconciles only that owner's latest exact state one owner per seal call, discovered relevant owners receive scoped exact updates, and visited unrelated owners receive only a cheap coalesced dirty notification before metered replay. Once topology sealing finishes, observed owners temporarily write through exactly until same-call activation; the finite pre-seal queue therefore drains even under continuous multi-owner movement. New drafts start at their captured journal suffix; old slots are superseded rather than reused behind live cursors and compact through the same meter. Unrelated churn therefore never restarts or starves target capture/sealing. Deterministically ordered concurrent preparations receive committed—not merely sealed—peer deltas and rebase one cache, graph, landblock, or owner leaf per seal step; cancellation therefore cannot leak unpublished topology. Demotion/withdrawal cancels a matching queued or active rebase, suppresses the prefix in unfinished source scans, and retires one owner/cache/graph/outdoor leaf per seal call. The complete previous generation remains queryable until one zero-managed-byte volatile root transfer in the same update-thread call as final reconciliation; that preserves PhysicsDataCache, CellGraph, PhysicsEngine, and ShadowObjectRegistry facade identity, revokes staging, and requires no quiet frame. A stale admission or staging failure disposes only that private generation and cannot withdraw the active world or invalidate a newer admission. Authored same-ID target statics, live-current-cell changes, owner departure/reuse, newly relevant seam-crossing statics, and teardown remain coherent across drafts; empty per-prefix owner containers are reclaimed without invalidating captured seal cursors. The commit clears repaired withdrawal markers before its single notification/readiness acknowledgement, so no optional hydration callback can omit reflood and no observer sees mixed old/new cells. | `src/AcDream.Runtime/Physics/RuntimePhysicsState.cs` (`PrepareCollisionGeneration`, `AdvanceCollisionGenerationPreparation`, `AdvanceCollisionGenerationSeal`, `CommitCollisionGeneration`); `src/AcDream.Core/Physics/CollisionWorldState.cs`; `PhysicsDataCache.cs`; `PhysicsEngine.cs`; `ShadowObjectRegistry.cs`; `src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs`; `src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs`; `tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsStateTests.cs`; `tests/AcDream.App.Tests/Streaming/LandblockPhysicsPublisherTests.cs`; `tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs` | — | — | `CObjCell::init_objects` → `CPhysicsObj::recalc_cross_cells`, 0x0052b420 / 0x00515a30; `CPhysicsObj::SetPositionInternal` shadow replacement tail 0x00515330 | | AD-10 | Remote slope projection relocated to the queue-empty/head-reached combiner boundary; retail projects inside `CTransition::adjust_offset` during the sweep | `src/AcDream.Core/Physics/PositionManager.cs:47` | Remote bodies don't run a full local transition sweep; boundary projection removes the ~5 Hz Z staircase on slopes, no-op on flat ground | The single-point terrain-normal sample can differ from the sweep's contact plane (cell boundaries, props underfoot) — remote Z drift / stair-stepping | `CTransition::adjust_offset` pc:272296-272346 | | ~~AD-11~~ | **RETIRED 2026-07-23** — the matching binary disproved the old nonzero interpretation: `ItemUses::IsUseable` executes `not bitfield; and eax,1`, so absent/reset zero is usable and only `USEABLE_NO` disables use. Toolbar, item policy, and world interaction now share that exact Core predicate. | `src/AcDream.Core/Items/ClientObject.cs` (`ItemUseability.IsUseable`); `src/AcDream.Core/Items/ItemInteractionPolicy.cs`; `src/AcDream.App/Interaction/WorldSelectionQuery.cs` | — | — | `ItemUses::IsUseable @ 0x004FCCC0`; matching v11.4186 instructions recorded in `docs/research/2026-07-23-retail-item-use-and-autowear-pseudocode.md` | | AD-12 | SecondaryAttributeTable coefficients hardcoded (Health=End×0.5, Stam=End×1.0, Mana=Self×1.0) instead of dat-read; unknown attributes contribute 0 | `src/AcDream.Core/Player/LocalPlayerState.cs:279` | Coefficients never vary across retail dat versions; re-confirmed by ACE AttributeFormula.cs + holtburger; dat port can replace later | A customized portal.dat with modified vital formulas silently yields wrong max-vitals; a missing attribute snapshot underestimates max | SecondaryAttributeTable portal.dat 0x0E0..0x0E2; `CreatureVital::GetMaxValue` 0x0058F2DD | diff --git a/docs/research/2026-07-31-atomic-collision-generation.md b/docs/research/2026-07-31-atomic-collision-generation.md index 0e92fba1..f5affecb 100644 --- a/docs/research/2026-07-31-atomic-collision-generation.md +++ b/docs/research/2026-07-31-atomic-collision-generation.md @@ -20,35 +20,86 @@ generation, and correctness depended on a later optional landblock callback. The asynchronous unit is now one Runtime-owned collision generation: 1. `BeginCollisionAdmission` issues the exact Runtime/landblock generation. -2. `PrepareCollisionGeneration` clones the bounded resident spatial records - into a private cache, graph, engine, and shadow registry. Global immutable - GfxObj/Setup catalogs are not copied; the accepted build's exact closure is - populated by the existing cursors. -3. App and Headless publish terrain, EnvCells, topology, buildings, prepared +2. `PrepareCollisionGeneration` creates empty private cache, graph, engine, and + shadow facades and retains the active aggregate root reference in O(1). + Global immutable GfxObj/Setup catalogs are not copied; the accepted build's + exact closure is populated by the existing cursors. +3. Stable + landblock and logical-owner slot suffixes then materialize each non-target + cache, CellGraph, engine, and shadow leaf into an empty private root under + the host's existing frame meter. The 32-resident-landblock gate proves + admission performs no resident copy and every advance reports at most one + work unit. +4. App and Headless publish terrain, EnvCells, topology, buildings, prepared collision assets, and target-root static owners only into that private generation. -4. A mutation-stable cursor captures every non-suspended owner that touches — - or has a withdrawn repair marker for — the target prefix. That includes - live dynamic owners and statics rooted in an adjacent landblock. Only a +5. Stable per-prefix owner slots capture every non-suspended owner that touches + or has a withdrawn repair marker for the target prefix. That includes live + dynamic owners and statics rooted in an adjacent landblock. Only a target-root static is omitted, because the authored replacement supersedes - it. Each retained owner refresh captures its exact - `ShadowObjectRegistry` mutation version; a rowless withdrawn owner therefore - remains freshness-gated when state or payload changes. -5. Explicit one-work-unit cursors build the complete replacement before the + it. The scan has a fixed slot suffix and is unaffected by mutations in other + prefixes. Vacated slots are tombstoned and reused rather than retained for + the whole session. A single Runtime-scoped versioned journal records each + mutation once and coalesces repeated changes by logical owner, independently + of the number of live drafts. After topology sealing, each draft reconciles + the latest exact state of owners changed during that draft's lifetime one + owner per seal call. A discovered relevant owner then receives scoped exact + updates, preserving continuous-motion progress without restoring global + fanout. A membership transition is routed by the owner's changed landblock + prefix to the one matching draft, so an owner first entering or leaving the + target after its global journal slot was visited is still reconciled once. + During topology construction, a visited unrelated owner retains only a cheap + coalesced notification; its exact mirror runs later as one metered seal unit + rather than once per draft on the mutation path. Once the topology seal + exists, observed owners temporarily write through exactly until same-call + activation. The finite pre-seal queue therefore drains even when two or more + unrelated owners mutate before every host step. Slots predating a newer root snapshot are superseded + by a tail slot, not reused behind live cursors. New drafts begin at their + captured suffix, obsolete slots compact one visit per seal call, and the + journal clears when its last draft closes. Unrelated and continuously moving + owners therefore never restart capture or sealing. +6. Explicit one-work-unit cursors build the complete replacement before the activation frame: requested global collision records, cells/topology, buildings, cell graph removals, affected static owners, retained-owner - states, and removal lists. An active-owner mutation restarts capture and - sealing without touching the active world. -6. `CommitCollisionGeneration` performs only the final mutation-version check - and installs the already sealed replacement synchronously on the update - thread. The dense 256-owner gate measures zero managed bytes in this final - activation. Only after the complete replacement does Runtime emit - `CollisionGenerationCommitted` and a ready acknowledgement. + states, and removal lists. A late unarmed relevant owner consumes at most + one refresh unit on a seal call; when that drains the queue an already-built + seal is immediately ready. Immutable global GfxObj/Setup closure entries are + preinstalled during these metered steps, not during activation. +7. Cache, CellGraph, engine-landblock, and shadow topology share one + `CollisionWorldStateSlot`. `CommitCollisionGeneration` transfers the + complete off-side aggregate through one volatile reference on the update + thread, then revokes the staging slot. The public `PhysicsDataCache`, + `CellGraph`, `PhysicsEngine`, and `ShadowObjectRegistry` facade identities + stay stable. Warm 256-owner, cold + first-load, changed EnvCell/building, and new static-bucket gates all measure + exactly zero managed bytes in final activation. Only afterwards does Runtime + emit `CollisionGenerationCommitted` and a ready acknowledgement. +8. Multiple landblocks may prepare concurrently. Preparation order is the + activation order. Only after an older generation commits is its exact delta + queued into every later draft. Each additional seal call applies at most one + cache, CellGraph, synthesized outdoor-cell, engine-landblock, or logical + owner leaf. Later generations retain their own completed target seal but + cannot activate before every committed delta drains. Cancelled older drafts + therefore contribute nothing, final activation performs no peer work, and a + later root cannot overwrite or expose an older snapshot. Seam-crossing + statics are forcibly re-evaluated against the later topology. Demotion and + withdrawal cancel a matching queued or active rebase, suppress that prefix + in unfinished source scans, and retire one shadow owner, cache/graph leaf, + authored outdoor cell, or landblock leaf per later seal call before + activation. Retirement storage is growable rather than coupled to the + concurrent-preparation limit, and final commit rechecks both pending rebase + and retirement work after the seal-to-commit gap. + The host performs the zero-work root transfer in the same update-thread call + that completes final reconciliation, eliminating a seal-to-next-frame quiet + window for continuously moving unrelated owners. +9. GfxObj/Setup closure entries are immutable content-addressed catalog data, + not world topology. Their metered early installation may survive a cancelled + generation as ordinary process cache residency; no cell, building, + landblock, or shadow becomes visible through that catalog alone. -The stable borrowed `PhysicsEngine` and `PhysicsDataCache` object identities do -not change. Presentation and no-window hosts use the same Runtime transaction. -Network workers still enqueue immutable messages and cannot mutate collision or -shadow state. +Presentation and no-window hosts use the same Runtime transaction. Network +workers still enqueue immutable messages and cannot mutate collision or shadow +state. ## Failure and lifetime rules @@ -71,17 +122,51 @@ The focused Runtime/App tests pin: - previous terrain/cells/buildings/statics remain visible until commit; - exactly one notification after a successful complete activation; - stale admission replacement has no active-world side effect; -- movement during staging rejects, refreshes only the dirty owner, and then - installs its latest cell set; -- an authoritative state change on a retained rowless owner rejects a stale - seal and installs the refreshed state on retry; +- unrelated movement on every capture/seal step never restarts the target; +- two relevant owners moving on every seal step converge without restarting + the topology meter and install their latest positions at activation; +- an authoritative state change on a retained rowless owner updates an + already-sealed generation without a global restart; - a neighboring static whose shadow crossed the seam is restored atomically on reload and its withdrawn-prefix marker clears only at activation; -- spawn and deletion during staging both reject stale activation; +- a late spawn blocks activation until its one metered refresh; deletion of an + armed owner writes through directly; - Headless faults immediately after admission and after staging preserve the prior complete world and leave no collision admission behind; -- dense sealing consumes at most one work unit per call, while its final - 256-owner activation allocates zero managed bytes; +- dense sealing consumes at most one work unit per call, while warm 256-owner, + cold first-load, changed EnvCell/building, and new static-bucket activation + all allocate zero managed bytes; +- concurrently prepared landblocks rebase and preserve both terrain roots and + static-shadow owners across their activation order, with zero-byte final + commits and revoked staging access; +- dense 32-landblock admission performs no resident copy, stays within its + constant allocation envelope, and materializes at most one leaf per advance; +- cancelled older drafts contribute no topology to later roots, while a live + owner mutation after the older commit wins over the queued rebase; +- a newly committed seam-crossing static refloods against the later draft's + topology before that draft may activate; +- post-seal arrivals drain one owner per seal call without resetting capture; +- an unrelated owner entering the target after its journal slot was visited is + routed by prefix and reconciled in one metered seal unit; +- target departure and same-ID reuse preserve the exact new-prefix owner rows; +- unrelated state mutation publishes only after every row changes; +- unrelated demotion/withdrawal and the live `CurrCell` cannot be resurrected + or rolled back by a later draft; +- queued and partially applied peer rebases cannot resurrect a later demoted or + withdrawn landblock; +- deleting an outgoing target static before, during, or after staging cannot + erase an authored same-ID replacement; +- 10,000 repeated mutations with 32 drafts retain one coalesced journal entry + and allocate no more than the owner mutation itself; +- 512 unique changed owners reconcile in exactly 512 metered seal units and + the final activation still allocates zero managed bytes; +- compacted journal slots are never reused behind a live cursor, while a + 4,096-slot obsolete tail retires incrementally and a later draft starts at + its captured suffix rather than scanning old tombstones; +- post-seal retirement blocks activation until its cursor drains, and more + than 256 distinct retirements remain metered and lossless; +- prefix-owner slots remain bounded under GUID churn and empty containers are + reclaimed across unique prefixes without invalidating a live seal cursor; - graphical and no-window publishers use the same Runtime transaction; - removal and terminal teardown converge the active ownership ledger. diff --git a/memory/project_collision_port.md b/memory/project_collision_port.md index f75de062..6ee52a74 100644 --- a/memory/project_collision_port.md +++ b/memory/project_collision_port.md @@ -15,11 +15,38 @@ as "delete everything and start over." A partial retail transition port exists: engine snapshot, buildings, statics, and versioned retained-owner refloods. Retained means every non-suspended dynamic or adjacent-root static touching (or withdrawn from) the target prefix; only authored target-root statics are - superseded. All scans and replacement construction run through bounded - capture/seal cursors. `RuntimePhysicsState.CommitCollisionGeneration` does - one final mutation-version check and activates the sealed generation without - managed allocation on the single update thread. Cancellation tears down only - the named staging generation. The old generation remains queryable until + superseded. Stable per-prefix owner slots bound capture independently of + unrelated movement. One Runtime-scoped versioned journal records a live + mutation once, coalesces repeated changes by owner, and lets every draft + reconcile only that owner's latest exact state one owner per seal step. + Relevant owners subscribe only after discovery, preserving exact continuous + updates without the former preparations-by-mutations global fanout or a quiet + frame. A changed prefix routes first target entry/departure to the one + matching draft after its journal cursor has passed. During topology build, + visited unrelated owners receive only coalesced dirty notifications; one + later seal unit performs the exact mirror. After the topology seal exists, + observed owners temporarily write through exactly until same-call activation, + guaranteeing that the finite dirty queue drains under multi-owner motion. + Old slots are superseded at the tail rather than reused behind + live cursors, new drafts start at their captured suffix, and obsolete slots + plus empty prefix containers are reclaimed under churn. + Admission captures the active aggregate + root reference in O(1), then stable landblock/owner suffixes materialize the + non-target cache, CellGraph, engine, and shadow leaves one work unit per host + step. All scans and replacement construction run + through bounded preparation/capture/seal cursors. Cache, CellGraph, engine, and shadow + topology share one complete off-side `CollisionWorldState`; final activation + is a single zero-allocation volatile root transfer that preserves borrowed + facade identities and revokes staging. Deterministically ordered concurrent + preparations queue their deltas into later drafts only after commit; each + seal call rebases at most one cache/graph/landblock/owner leaf. Demotion or + withdrawal cancels matching queued/active rebases, suppresses stale source- + clone leaves, and retires one owner/cache/graph/outdoor leaf per seal step + from growable storage; commit rechecks pending retirement after sealing. + The host performs the zero-work root transfer in the same update-thread call + that completes final reconciliation, avoiding a quiet-frame requirement. + Cancellation tears down + only the named staging generation. The old generation remains queryable until commit. See `docs/research/2026-07-31-atomic-collision-generation.md`. - `ShadowObjectRegistry` gives the resolver a broadphase over nearby objects. diff --git a/src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs b/src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs index 8f34842d..1144cf08 100644 --- a/src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs +++ b/src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs @@ -265,6 +265,13 @@ public sealed class LandblockPhysicsPublisher if (publication.PreparationCommitted) return true; + if (!_physics.AdvanceCollisionGenerationPreparation( + publication.CollisionAdmission, + publication.PreparedGeneration).Completed) + { + return false; + } + IReadOnlyList entities = publication.Build.Landblock.Entities; if (publication.Build.Collisions is { } collisions) @@ -548,7 +555,13 @@ public sealed class LandblockPhysicsPublisher publication.RefloodCommitted = false; } } - else + + // Seal reconciliation and the zero-work root transfer are one host + // update-thread transaction. Deferring this commit to the next frame + // would let continuously moving, unrelated live owners dirty their + // already-visited journal slots forever even though each metered replay + // had just caught up exactly. + if (publication.SealCommitted && !publication.CompletionCommitted) { RuntimeCollisionGenerationCommit commit = _physics.CommitCollisionGeneration( @@ -556,12 +569,9 @@ public sealed class LandblockPhysicsPublisher publication.PreparedGeneration); if (!commit.Committed) { - _physics.RestartCollisionRetainedOwnerCapture( - publication.CollisionAdmission, - publication.PreparedGeneration); - publication.RefloodOwnerIds = null; - publication.RefloodCursor = 0; - publication.RefloodCommitted = false; + // Runtime coalesces post-seal arrivals in its owner journal. + // Resume that seal tail rather than restarting the + // completed generation-wide capture/reflood pass. publication.SealCommitted = false; _completePublishTicks += Stopwatch.GetTimestamp() - started; return false; diff --git a/src/AcDream.Core/Physics/CollisionWorldState.cs b/src/AcDream.Core/Physics/CollisionWorldState.cs new file mode 100644 index 00000000..a2a182c2 --- /dev/null +++ b/src/AcDream.Core/Physics/CollisionWorldState.cs @@ -0,0 +1,86 @@ +using System.Collections.Concurrent; +using AcDream.Core.World.Cells; + +namespace AcDream.Core.Physics; + +/// +/// One exclusive-by-ownership collision-world root. A preparation mutates only +/// its private root; activation transfers the complete root through one volatile +/// reference publication shared by every collision facade. +/// +internal sealed class CollisionWorldState +{ + internal Dictionary Landblocks { get; } = new(); + internal List LandblockSlots { get; } = new(); + internal Dictionary LandblockIndices { get; } = new(); + internal Stack LandblockFreeSlots { get; } = new(); + internal ConcurrentDictionary CellStruct { get; } = new(); + internal ConcurrentDictionary + FlatCellStruct { get; } = new(); + internal ConcurrentDictionary FlatEnvCell { get; } = new(); + internal ConcurrentDictionary Buildings { get; } = new(); + internal ConcurrentDictionary EnvCells { get; } = new(); + internal ConcurrentDictionary Terrain { get; } = new(); + internal ConcurrentDictionary OutdoorCells { get; } = new(); + internal Dictionary> ShadowCells { get; } = new(); + internal Dictionary> ShadowEntityCells { get; } = new(); + internal HashSet SuspendedShadowEntities { get; } = new(); + internal Dictionary> WithdrawnPrefixesByOwner { get; } = new(); + internal Dictionary> ShadowEntityShapes { get; } = new(); + internal Dictionary + ShadowEntityRegistrations { get; } = new(); + internal Dictionary ShadowOwnerVersions { get; } = new(); + internal Dictionary> ShadowOwnerPrefixes { get; } = new(); + internal Dictionary> ShadowPrefixOwnerSlots { get; } = new(); + internal Dictionary> ShadowPrefixOwnerIndices { get; } = new(); + internal Dictionary> ShadowPrefixFreeSlots { get; } = new(); + internal List ShadowOwnerSlots { get; } = new(); + internal Dictionary ShadowOwnerIndices { get; } = new(); + internal Stack ShadowOwnerFreeSlots { get; } = new(); +} + +/// +/// Stable indirection shared by PhysicsEngine, PhysicsDataCache, CellGraph, +/// and ShadowObjectRegistry. Readers observe either complete root, never a +/// mixture assembled by several facade assignments. +/// +internal sealed class CollisionWorldStateSlot +{ + private CollisionWorldState? _current = new(); + private bool _revoked; + + internal CollisionWorldStateSlot() + { + } + + internal CollisionWorldStateSlot(CollisionWorldState current) + { + _current = current ?? throw new ArgumentNullException(nameof(current)); + } + + internal CollisionWorldState Current + { + get + { + if (_revoked) + throw new ObjectDisposedException("Transferred collision generation"); + return Volatile.Read(ref _current) + ?? throw new ObjectDisposedException("Transferred collision generation"); + } + } + + internal CollisionWorldState TransferTo(CollisionWorldStateSlot destination) + { + ArgumentNullException.ThrowIfNull(destination); + if (_revoked) + throw new ObjectDisposedException("Transferred collision generation"); + CollisionWorldState transferred = _current + ?? throw new ObjectDisposedException("Transferred collision generation"); + _revoked = true; + Volatile.Write(ref destination._current, transferred); + _current = null; + return transferred; + } + + internal CollisionWorldState Capture() => Current; +} diff --git a/src/AcDream.Core/Physics/PhysicsDataCache.cs b/src/AcDream.Core/Physics/PhysicsDataCache.cs index 8be11764..ad2a2b9d 100644 --- a/src/AcDream.Core/Physics/PhysicsDataCache.cs +++ b/src/AcDream.Core/Physics/PhysicsDataCache.cs @@ -23,18 +23,20 @@ public sealed class PhysicsDataCache { private readonly bool _requirePreparedCollision; private PhysicsDataCache? _readFallback; + private readonly CollisionWorldStateSlot _collisionWorld; private readonly ConcurrentDictionary _gfxObj = new(); private readonly ConcurrentDictionary _visualBounds = new(); private readonly ConcurrentDictionary _setup = new(); - private readonly ConcurrentDictionary _cellStruct = new(); + private ConcurrentDictionary _cellStruct => + _collisionWorld.Current.CellStruct; private readonly ConcurrentDictionary _flatGfxObj = new(); private readonly ConcurrentDictionary _flatSetup = new(); - private readonly ConcurrentDictionary - _flatCellStruct = new(); - private readonly ConcurrentDictionary - _flatEnvCell = new(); + private ConcurrentDictionary + _flatCellStruct => _collisionWorld.Current.FlatCellStruct; + private ConcurrentDictionary + _flatEnvCell => _collisionWorld.Current.FlatEnvCell; public PhysicsDataCache() : this(requirePreparedCollision: false) @@ -42,8 +44,18 @@ public sealed class PhysicsDataCache } private PhysicsDataCache(bool requirePreparedCollision) + : this(requirePreparedCollision, new CollisionWorldStateSlot()) + { + } + + private PhysicsDataCache( + bool requirePreparedCollision, + CollisionWorldStateSlot collisionWorld) { _requirePreparedCollision = requirePreparedCollision; + _collisionWorld = collisionWorld + ?? throw new ArgumentNullException(nameof(collisionWorld)); + CellGraph = new UcgCellGraph(_collisionWorld); if (!requirePreparedCollision && PhysicsDiagnostics.CollisionShadowSampleEvery > 0) { @@ -67,6 +79,18 @@ public sealed class PhysicsDataCache return cache; } + internal static PhysicsDataCache CreateProduction( + CollisionWorldStateSlot collisionWorld) + { + var cache = new PhysicsDataCache( + requirePreparedCollision: true, + collisionWorld) + { + CollisionTraversalMode = CollisionTraversalMode.Flat, + }; + return cache; + } + internal CollisionShadowVerifier? CollisionShadow { get; set; } public CollisionShadowStats CollisionShadowStats => @@ -80,7 +104,8 @@ public sealed class PhysicsDataCache CollisionTraversalMode.Graph; // ── Phase 2: building portal cache for outdoor→indoor entry ─────────── - private readonly ConcurrentDictionary _buildings = new(); + private ConcurrentDictionary _buildings => + _collisionWorld.Current.Buildings; /// /// The unified cell graph (UCG): the active id->cell resolver and registry. @@ -94,31 +119,23 @@ public sealed class PhysicsDataCache /// (TryGetTerrainOrigin, read by CellTransit's pick + transit /// paths). No longer inert. /// - public UcgCellGraph CellGraph { get; private set; } = new(); + public UcgCellGraph CellGraph { get; } + + internal CollisionWorldStateSlot CollisionWorld => _collisionWorld; /// - /// Copies the currently committed immutable collision records into an - /// off-side cache. Streaming may replace one landblock in this copy over - /// many frames without exposing a partially withdrawn cell graph to live - /// physics queries. + /// Creates the empty off-side facade used by Runtime's metered root + /// materializer. Global immutable catalogs fall through to this cache; + /// mutable world topology is installed one leaf per host step. /// - internal PhysicsDataCache CreateCollisionStagingCopy() + internal PhysicsDataCache CreateEmptyCollisionStaging( + CollisionWorldStateSlot collisionWorld) { - var copy = new PhysicsDataCache(_requirePreparedCollision) + return new PhysicsDataCache(_requirePreparedCollision, collisionWorld) { CollisionTraversalMode = CollisionTraversalMode, - CellGraph = CellGraph.CreateCollisionStagingCopy(), _readFallback = this, }; - // Global immutable GfxObj/Setup records are not copied wholesale. - // The accepted build's exact closure is staged cursor-by-cursor below; - // copying the process-retained asset catalog here would turn every - // landblock publication into an unbounded frame spike. - CopyDictionary(_cellStruct, copy._cellStruct); - CopyDictionary(_flatCellStruct, copy._flatCellStruct); - CopyDictionary(_flatEnvCell, copy._flatEnvCell); - CopyDictionary(_buildings, copy._buildings); - return copy; } internal LandblockReplacementBuilder CreateLandblockReplacementBuilder( @@ -132,56 +149,6 @@ public sealed class PhysicsDataCache gfxObjectIds, setupIds); - internal void CommitLandblockReplacement( - PreparedPhysicsDataCacheLandblock replacement) - { - RemoveEntries(_cellStruct, replacement.CellIdsToRemove); - RemoveEntries(_flatCellStruct, replacement.FlatCellIdsToRemove); - RemoveEntries(_flatEnvCell, replacement.FlatEnvCellIdsToRemove); - RemoveEntries(_buildings, replacement.BuildingIdsToRemove); - CommitEntries(_gfxObj, replacement.GfxObjects, replace: false); - CommitEntries(_visualBounds, replacement.VisualBounds, replace: false); - CommitEntries(_flatGfxObj, replacement.FlatGfxObjects, replace: false); - CommitEntries(_setup, replacement.Setups, replace: false); - CommitEntries(_flatSetup, replacement.FlatSetups, replace: false); - CommitEntries(_cellStruct, replacement.Cells, replace: true); - CommitEntries(_flatCellStruct, replacement.FlatCells, replace: true); - CommitEntries(_flatEnvCell, replacement.FlatEnvCells, replace: true); - CommitEntries(_buildings, replacement.Buildings, replace: true); - CellGraph.CommitLandblockReplacement(replacement.CellGraph); - } - - private static void CopyDictionary( - ConcurrentDictionary source, - ConcurrentDictionary destination) - { - foreach ((uint id, T value) in source) - destination.TryAdd(id, value); - } - - private static void CommitEntries( - ConcurrentDictionary destination, - IReadOnlyList> entries, - bool replace) - { - for (int index = 0; index < entries.Count; index++) - { - (uint id, T value) = entries[index]; - if (replace) - destination[id] = value; - else - destination.TryAdd(id, value); - } - } - - private static void RemoveEntries( - ConcurrentDictionary destination, - IReadOnlyList ids) - { - for (int index = 0; index < ids.Count; index++) - destination.TryRemove(ids[index], out _); - } - /// /// Extract and cache the physics BSP + polygon data from a GfxObj, /// PLUS always cache a visual AABB from the vertex data regardless of @@ -1085,6 +1052,9 @@ public sealed class PhysicsDataCache if (_cursor < _gfxIds.Length) { uint id = _gfxIds[_cursor++]; + Preinstall(_staging._gfxObj, _active._gfxObj, id); + Preinstall(_staging._visualBounds, _active._visualBounds, id); + Preinstall(_staging._flatGfxObj, _active._flatGfxObj, id); Capture(_staging._gfxObj, id, _gfx); Capture(_staging._visualBounds, id, _bounds); Capture(_staging._flatGfxObj, id, _flatGfx); @@ -1098,6 +1068,8 @@ public sealed class PhysicsDataCache if (_cursor < _setupIds.Length) { uint id = _setupIds[_cursor++]; + Preinstall(_staging._setup, _active._setup, id); + Preinstall(_staging._flatSetup, _active._flatSetup, id); Capture(_staging._setup, id, _setups); Capture(_staging._flatSetup, id, _flatSetups); WorkUnits++; @@ -1229,6 +1201,15 @@ public sealed class PhysicsDataCache destination.Add(new KeyValuePair(id, value)); } + private static void Preinstall( + ConcurrentDictionary source, + ConcurrentDictionary destination, + uint id) + { + if (source.TryGetValue(id, out T? value)) + destination.TryAdd(id, value); + } + private static bool CapturePrefixOne( IEnumerator> enumerator, uint prefix, diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs index cd7e6b07..d4d7610b 100644 --- a/src/AcDream.Core/Physics/PhysicsEngine.cs +++ b/src/AcDream.Core/Physics/PhysicsEngine.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Collections.Immutable; using System.Numerics; using AcDream.Core.Items; +using AcDream.Core.World.Cells; namespace AcDream.Core.Physics; @@ -28,7 +29,15 @@ internal readonly record struct TerrainWalkableSample( /// public sealed class PhysicsEngine { - private readonly Dictionary _landblocks = new(); + private CollisionWorldStateSlot _collisionWorld; + private Dictionary _landblocks => + _collisionWorld.Current.Landblocks; + private List _landblockSlots => + _collisionWorld.Current.LandblockSlots; + private Dictionary _landblockIndices => + _collisionWorld.Current.LandblockIndices; + private Stack _landblockFreeSlots => + _collisionWorld.Current.LandblockFreeSlots; private readonly TransitionScratchArena? _transitionScratch; public PhysicsEngine() @@ -43,6 +52,8 @@ public sealed class PhysicsEngine /// internal PhysicsEngine(bool reuseTransitionScratch) { + _collisionWorld = new CollisionWorldStateSlot(); + ShadowObjects = new ShadowObjectRegistry(_collisionWorld); _transitionScratch = reuseTransitionScratch ? new TransitionScratchArena() : null; @@ -57,6 +68,8 @@ public sealed class PhysicsEngine /// Number of registered landblocks (diagnostic). public int LandblockCount => _landblocks.Count; + internal CollisionWorldStateSlot CollisionWorld => _collisionWorld; + /// /// Optional high-volume collision trace sink. Production leaves this /// unset; focused diagnostic gates may opt in explicitly. @@ -85,7 +98,7 @@ public sealed class PhysicsEngine public bool IsLandblockTerrainResident(uint cellOrLandblockId) { uint prefix = cellOrLandblockId & 0xFFFF0000u; - foreach (var key in _landblocks.Keys) + foreach ((uint key, _) in _landblocks) if ((key & 0xFFFF0000u) == prefix) return true; return false; } @@ -103,7 +116,8 @@ public sealed class PhysicsEngine public bool IsNeighborhoodTerrainResident(uint cellOrLandblockId, int radius) { var resident = new HashSet(); - foreach (var key in _landblocks.Keys) resident.Add(key & 0xFFFF0000u); + foreach ((uint key, _) in _landblocks) + resident.Add(key & 0xFFFF0000u); int cx = (int)((cellOrLandblockId >> 24) & 0xFFu); int cy = (int)((cellOrLandblockId >> 16) & 0xFFu); @@ -122,7 +136,7 @@ public sealed class PhysicsEngine /// Cell-based spatial index for static object collision. /// Populated during landblock streaming; queried by the Transition system. /// - public ShadowObjectRegistry ShadowObjects { get; } = new(); + public ShadowObjectRegistry ShadowObjects { get; } /// /// Physics BSP cache shared with the streaming loader. Set once by the @@ -135,10 +149,61 @@ public sealed class PhysicsEngine public PhysicsDataCache? DataCache { get => _dataCache; - set { _dataCache = value; ShadowObjects.DataCache = value; } + set + { + if (value is not null + && !ReferenceEquals(_collisionWorld, value.CollisionWorld)) + { + if (ShadowObjects.TotalRegistered != 0) + { + throw new InvalidOperationException( + "A populated physics engine cannot change collision roots."); + } + // Legacy/test construction commonly installs terrain before + // attaching its first cache. Preserve that one-time ordering + // without permitting a live populated root swap: move only + // the engine-owned landblock index into the still-private + // cache root, then bind the stable facades. + if (_dataCache is null && _landblocks.Count != 0) + CopyDetachedLandblocksTo(value.CollisionWorld); + else if (_landblocks.Count != 0) + throw new InvalidOperationException( + "A populated physics engine cannot change collision roots."); + _collisionWorld = value.CollisionWorld; + ShadowObjects.AttachCollisionWorld(_collisionWorld); + } + _dataCache = value; + ShadowObjects.DataCache = value; + } } private PhysicsDataCache? _dataCache; + private void CopyDetachedLandblocksTo( + CollisionWorldStateSlot destinationSlot) + { + CollisionWorldState source = _collisionWorld.Current; + CollisionWorldState destination = destinationSlot.Current; + if (destination.Landblocks.Count != 0 + || destination.LandblockSlots.Count != 0 + || destination.LandblockIndices.Count != 0 + || destination.LandblockFreeSlots.Count != 0) + { + throw new InvalidOperationException( + "A cache collision root already owns engine landblocks."); + } + foreach ((uint landblockId, LandblockPhysics landblock) in + source.Landblocks) + { + destination.Landblocks.Add(landblockId, landblock); + } + destination.LandblockSlots.AddRange(source.LandblockSlots); + foreach ((uint landblockId, int slot) in source.LandblockIndices) + destination.LandblockIndices.Add(landblockId, slot); + int[] freeSlots = source.LandblockFreeSlots.ToArray(); + for (int index = freeSlots.Length - 1; index >= 0; index--) + destination.LandblockFreeSlots.Push(freeSlots[index]); + } + /// /// AP-129 (Campaign P Slice P4 review fix, 2026-07-30): optional live /// weenie-object table, consulted ONLY by @@ -165,21 +230,16 @@ public sealed class PhysicsEngine /// Streaming modifies this copy only; the active engine and its borrowed /// cache/registry identities remain stable until Runtime commits. /// - internal PhysicsEngine CreateCollisionStagingCopy( - PhysicsDataCache stagingCache) + internal CollisionStagingBuilder CreateCollisionStagingBuilder( + uint targetLandblockId) { - ArgumentNullException.ThrowIfNull(stagingCache); - var staging = new PhysicsEngine - { - DataCache = stagingCache, - Objects = Objects, - }; - foreach ((uint id, LandblockPhysics landblock) in _landblocks) - staging._landblocks[id] = landblock; - staging.ShadowObjects.CopyCollisionStateFrom( - ShadowObjects, - stagingCache); - return staging; + PhysicsDataCache activeCache = DataCache + ?? throw new InvalidOperationException( + "Active collision engine has no data cache."); + return new CollisionStagingBuilder( + this, + activeCache, + targetLandblockId & 0xFFFF0000u); } internal LandblockReplacementBuilder CreateLandblockReplacementBuilder( @@ -187,7 +247,7 @@ public sealed class PhysicsEngine uint landblockId, uint[] gfxObjectIds, uint[] setupIds, - IReadOnlyDictionary expectedRetainedVersions) + IReadOnlyList expectedRetainedOwners) { ArgumentNullException.ThrowIfNull(staging); uint canonical = (landblockId & 0xFFFF0000u) | 0xFFFFu; @@ -204,6 +264,7 @@ public sealed class PhysicsEngine return new LandblockReplacementBuilder( canonical, landblock, + staging, (DataCache ?? throw new InvalidOperationException( "Active collision engine has no data cache.")) .CreateLandblockReplacementBuilder( @@ -214,17 +275,721 @@ public sealed class PhysicsEngine ShadowObjects.CreateLandblockReplacementBuilder( staging.ShadowObjects, canonical, - expectedRetainedVersions)); + expectedRetainedOwners)); } internal void CommitLandblockReplacement( PreparedPhysicsEngineLandblock replacement) { - (DataCache ?? throw new InvalidOperationException( - "Active collision engine has no data cache.")) - .CommitLandblockReplacement(replacement.DataCache); - _landblocks[replacement.LandblockId] = replacement.Landblock; - ShadowObjects.CommitLandblockReplacement(replacement.Shadows); + PhysicsDataCache activeCache = DataCache + ?? throw new InvalidOperationException( + "Active collision engine has no data cache."); + PhysicsDataCache stagingCache = replacement.Staging.DataCache + ?? throw new InvalidOperationException( + "Staging collision engine has no data cache."); + uint activeCurrentCellId = activeCache.CellGraph.CurrCell?.Id ?? 0u; + stagingCache.CollisionWorld.TransferTo(activeCache.CollisionWorld); + if ((activeCurrentCellId & 0xFFFF0000u) + == (replacement.LandblockId & 0xFFFF0000u)) + { + activeCache.CellGraph.CurrCell = + activeCache.CellGraph.GetVisible(activeCurrentCellId); + } + } + + internal LandblockReplacementApplyCursor + CreateLandblockReplacementApplyCursor( + PreparedPhysicsEngineLandblock replacement) => + new(this, replacement); + + internal readonly record struct LandblockReplacementApplyStep( + bool Completed, + bool Worked, + bool HasOwner, + uint OwnerId); + + internal LandblockRetirementCursor CreateLandblockRetirementCursor( + PhysicsEngine authoritative, + uint landblockId, + bool withdraw) => new( + this, + authoritative, + landblockId, + withdraw); + + /// + /// Applies one demotion/withdrawal to an off-side root without a whole- + /// world synchronous scan. Every advance inspects or mutates at most one + /// stable owner slot, dictionary leaf, or authored outdoor cell. + /// + internal sealed class LandblockRetirementCursor : IDisposable + { + private readonly PhysicsEngine _destinationEngine; + private readonly PhysicsDataCache _destinationCache; + private readonly CollisionWorldState _destination; + private readonly CollisionWorldState _authoritative; + private readonly uint _canonical; + private readonly uint _prefix; + private readonly bool _withdraw; + private readonly List _ownerSlots; + private readonly int _ownerSlotLimit; + private readonly LandblockPhysics? _demotedLandblock; + private readonly CellGraphTerrain? _demotedTerrain; + private IEnumerator>? _cells; + private IEnumerator>? + _flatCells; + private IEnumerator>? _flatEnvCells; + private IEnumerator>? _buildings; + private IEnumerator>? _envCells; + private int _ownerIndex; + private int _outdoorIndex; + private int _phase; + + internal LandblockRetirementCursor( + PhysicsEngine destination, + PhysicsEngine authoritative, + uint landblockId, + bool withdraw) + { + _destinationEngine = destination; + _destinationCache = destination.DataCache + ?? throw new InvalidOperationException( + "Collision engine has no data cache."); + _destination = destination._collisionWorld.Capture(); + _authoritative = authoritative._collisionWorld.Capture(); + _canonical = (landblockId & 0xFFFF0000u) | 0xFFFFu; + _prefix = landblockId & 0xFFFF0000u; + _withdraw = withdraw; + _ownerSlots = _destination.ShadowOwnerSlots; + _ownerSlotLimit = _ownerSlots.Count; + if (!withdraw) + { + _authoritative.Landblocks.TryGetValue( + _canonical, + out _demotedLandblock); + _authoritative.Terrain.TryGetValue( + _prefix, + out _demotedTerrain); + } + } + + internal uint LandblockId => _canonical; + + internal LandblockRetirementStep Advance() + { + while (true) + { + switch (_phase) + { + case 0: + if (_ownerIndex < _ownerSlotLimit) + { + uint ownerId = _ownerSlots[_ownerIndex++]; + if (ownerId != 0u) + { + _destinationEngine.ShadowObjects + .RetireOwnerFromLandblock( + ownerId, + _canonical); + } + return Worked(); + } + _phase++; + continue; + case 1: + _cells ??= _destination.CellStruct.GetEnumerator(); + if (RemoveOneInPrefix(_cells, _destination.CellStruct)) + return Worked(); + DisposeEnumerator(ref _cells); + _phase++; + continue; + case 2: + _flatCells ??= _destination.FlatCellStruct.GetEnumerator(); + if (RemoveOneInPrefix( + _flatCells, + _destination.FlatCellStruct)) + return Worked(); + DisposeEnumerator(ref _flatCells); + _phase++; + continue; + case 3: + _flatEnvCells ??= _destination.FlatEnvCell.GetEnumerator(); + if (RemoveOneInPrefix( + _flatEnvCells, + _destination.FlatEnvCell)) + return Worked(); + DisposeEnumerator(ref _flatEnvCells); + _phase++; + continue; + case 4: + _buildings ??= _destination.Buildings.GetEnumerator(); + if (RemoveOneInPrefix( + _buildings, + _destination.Buildings)) + return Worked(); + DisposeEnumerator(ref _buildings); + _phase++; + continue; + case 5: + _envCells ??= _destination.EnvCells.GetEnumerator(); + if (RemoveOneInPrefix(_envCells, _destination.EnvCells)) + return Worked(); + DisposeEnumerator(ref _envCells); + _phase++; + continue; + case 6: + if (_outdoorIndex < 0x40) + { + uint id = _prefix | (uint)++_outdoorIndex; + _destination.ShadowCells.Remove(id); + if (_withdraw) + { + _destination.OutdoorCells.TryRemove(id, out _); + } + else if (_authoritative.OutdoorCells.TryGetValue( + id, + out ObjCell? outdoor)) + { + _destination.OutdoorCells[id] = outdoor; + } + return Worked(); + } + _phase++; + continue; + case 7: + if (_withdraw) + { + _destinationEngine._landblocks.Remove(_canonical); + _destinationEngine.RemoveLandblockSlot(_canonical); + _destination.Terrain.TryRemove(_prefix, out _); + } + else + { + if (_demotedLandblock is not null) + { + _destinationEngine._landblocks[_canonical] = + _demotedLandblock; + _destinationEngine.EnsureLandblockSlot(_canonical); + } + if (_demotedTerrain is not null) + _destination.Terrain[_prefix] = _demotedTerrain; + } + _phase++; + return Worked(); + case 8: + uint currentCellId = + _destinationCache.CellGraph.CurrCell?.Id ?? 0u; + if ((currentCellId & 0xFFFF0000u) == _prefix + && (_withdraw + || (currentCellId & 0xFFFFu) >= 0x0100u)) + { + _destinationCache.CellGraph.CurrCell = null; + } + _phase++; + return new LandblockRetirementStep( + Completed: true, + Worked: false); + default: + return new LandblockRetirementStep( + Completed: true, + Worked: false); + } + } + } + + private LandblockRetirementStep Worked() => new( + Completed: false, + Worked: true); + + private bool RemoveOneInPrefix( + IEnumerator> source, + IDictionary destination) + { + if (!source.MoveNext()) + return false; + uint id = source.Current.Key; + if ((id & 0xFFFF0000u) == _prefix) + { + destination.Remove(id); + _destination.ShadowCells.Remove(id); + } + return true; + } + + private static void DisposeEnumerator( + ref IEnumerator>? enumerator) + { + enumerator?.Dispose(); + enumerator = null; + } + + public void Dispose() + { + DisposeEnumerator(ref _cells); + DisposeEnumerator(ref _flatCells); + DisposeEnumerator(ref _flatEnvCells); + DisposeEnumerator(ref _buildings); + DisposeEnumerator(ref _envCells); + } + } + + internal readonly record struct LandblockRetirementStep( + bool Completed, + bool Worked); + + /// + /// Applies one already-committed landblock delta to a later off-side root. + /// Each advance mutates at most one dictionary leaf, one synthesized + /// outdoor cell, or one logical shadow owner. + /// + internal sealed class LandblockReplacementApplyCursor : IDisposable + { + private readonly PhysicsEngine _destinationEngine; + private readonly PhysicsDataCache _destinationCache; + private readonly CollisionWorldState _destination; + private readonly PreparedPhysicsEngineLandblock _replacement; + private int _phase; + private int _index; + + internal LandblockReplacementApplyCursor( + PhysicsEngine destination, + PreparedPhysicsEngineLandblock replacement) + { + _destinationEngine = destination; + _destinationCache = destination.DataCache + ?? throw new InvalidOperationException( + "Collision engine has no data cache."); + _destination = _destinationCache.CollisionWorld.Capture(); + _replacement = replacement; + } + + internal uint LandblockId => _replacement.LandblockId; + + internal LandblockReplacementApplyStep Advance() + { + PreparedPhysicsDataCacheLandblock data = _replacement.DataCache; + PreparedCellGraphLandblock graph = data.CellGraph; + while (true) + { + switch (_phase) + { + case 0: + if (RemoveOne(_destination.CellStruct, data.CellIdsToRemove)) + return Worked(); + NextPhase(); + continue; + case 1: + if (InstallOne(_destination.CellStruct, data.Cells)) + return Worked(); + NextPhase(); + continue; + case 2: + if (RemoveOne(_destination.FlatCellStruct, data.FlatCellIdsToRemove)) + return Worked(); + NextPhase(); + continue; + case 3: + if (InstallOne(_destination.FlatCellStruct, data.FlatCells)) + return Worked(); + NextPhase(); + continue; + case 4: + if (RemoveOne(_destination.FlatEnvCell, data.FlatEnvCellIdsToRemove)) + return Worked(); + NextPhase(); + continue; + case 5: + if (InstallOne(_destination.FlatEnvCell, data.FlatEnvCells)) + return Worked(); + NextPhase(); + continue; + case 6: + if (RemoveOne(_destination.Buildings, data.BuildingIdsToRemove)) + return Worked(); + NextPhase(); + continue; + case 7: + if (InstallOne(_destination.Buildings, data.Buildings)) + return Worked(); + NextPhase(); + continue; + case 8: + if (RemoveOne(_destination.EnvCells, graph.EnvCellIdsToRemove)) + return Worked(); + NextPhase(); + continue; + case 9: + if (graph.HasTerrain) + { + if (_index == 0) + { + _destination.Terrain[graph.LandblockPrefix] = + graph.Terrain!; + _index++; + return Worked(); + } + if (_index <= 0x40) + { + uint low = (uint)_index++; + CellGraphTerrain terrain = graph.Terrain!; + int cellIndex = (int)(low - 1u); + uint id = graph.LandblockPrefix | low; + _destination.OutdoorCells[id] = + LandCell.Synthesize( + id, + terrain.Terrain, + terrain.Origin, + cellIndex / 8, + cellIndex % 8); + return Worked(); + } + } + else + { + if (_index == 0) + { + _destination.Terrain.TryRemove( + graph.LandblockPrefix, + out _); + _index++; + return Worked(); + } + if (_index <= 0x40) + { + uint low = (uint)_index++; + _destination.OutdoorCells.TryRemove( + graph.LandblockPrefix | low, + out _); + return Worked(); + } + } + NextPhase(); + continue; + case 10: + if (InstallOne(_destination.EnvCells, graph.EnvCells)) + return Worked(); + NextPhase(); + continue; + case 11: + _destinationEngine.InstallLandblockClone( + _replacement.LandblockId, + _replacement.Landblock); + NextPhase(); + return Worked(); + case 12: + if (_index < _replacement.Shadows.OwnerIds.Count) + { + uint ownerId = + _replacement.Shadows.OwnerIds[_index++]; + return new LandblockReplacementApplyStep( + Completed: false, + Worked: true, + HasOwner: true, + ownerId); + } + NextPhase(); + continue; + case 13: + uint currentCellId = + _destinationCache.CellGraph.CurrCell?.Id ?? 0u; + if ((currentCellId & 0xFFFF0000u) + == graph.LandblockPrefix) + { + _destinationCache.CellGraph.CurrCell = + _destinationCache.CellGraph.GetVisible( + currentCellId); + } + _phase++; + return new LandblockReplacementApplyStep( + Completed: true, + Worked: false, + HasOwner: false, + OwnerId: 0u); + default: + return new LandblockReplacementApplyStep( + Completed: true, + Worked: false, + HasOwner: false, + OwnerId: 0u); + } + } + } + + private LandblockReplacementApplyStep Worked() => new( + Completed: false, + Worked: true, + HasOwner: false, + OwnerId: 0u); + + private void NextPhase() + { + _phase++; + _index = 0; + } + + private bool RemoveOne( + IDictionary destination, + IReadOnlyList ids) + { + if (_index >= ids.Count) + return false; + destination.Remove(ids[_index++]); + return true; + } + + private bool InstallOne( + IDictionary destination, + IReadOnlyList> entries) + { + if (_index >= entries.Count) + return false; + KeyValuePair pair = entries[_index++]; + destination[pair.Key] = pair.Value; + return true; + } + + public void Dispose() + { + } + } + + /// + /// Retained one-leaf-at-a-time materializer for an off-side collision + /// generation. Construction captures the current root reference only; no + /// resident dictionary is copied until . Runtime's + /// owner journal reconciles mutations that occur while this cursor walks. + /// + internal sealed class CollisionStagingBuilder : IDisposable + { + private readonly PhysicsEngine _active; + private readonly CollisionWorldState _source; + private readonly CollisionWorldState _destination; + private readonly ShadowObjectRegistry _sourceShadows; + private readonly uint _targetPrefix; + private readonly HashSet _suppressedPrefixes = new(); + private readonly int _landblockSlotLimit; + private readonly int _ownerSlotLimit; + private IEnumerator>? _cells; + private IEnumerator>? _flatCells; + private IEnumerator>? _flatEnvCells; + private IEnumerator>? _buildings; + private IEnumerator>? _envCells; + private IEnumerator>? _terrain; + private IEnumerator>? _outdoorCells; + private int _landblockIndex; + private int _ownerIndex; + private int _phase; + + internal CollisionStagingBuilder( + PhysicsEngine active, + PhysicsDataCache activeCache, + uint targetPrefix) + { + _active = active; + _targetPrefix = targetPrefix; + _source = active._collisionWorld.Capture(); + var stagingSlot = new CollisionWorldStateSlot(); + StagingCache = activeCache.CreateEmptyCollisionStaging(stagingSlot); + StagingEngine = new PhysicsEngine + { + DataCache = StagingCache, + Objects = active.Objects, + }; + _destination = stagingSlot.Capture(); + _sourceShadows = new ShadowObjectRegistry( + new CollisionWorldStateSlot(_source)); + _landblockSlotLimit = _source.LandblockSlots.Count; + _ownerSlotLimit = _source.ShadowOwnerSlots.Count; + } + + internal PhysicsDataCache StagingCache { get; } + internal PhysicsEngine StagingEngine { get; } + internal int WorkUnits { get; private set; } + internal bool Completed => _phase == 9; + + /// + /// Prevent a landblock retired after this cursor captured its source + /// root from being copied back into the draft by a later phase. + /// Already-copied leaves are retired by the caller before cloning + /// resumes; this tombstone covers every leaf not visited yet. + /// + internal void SuppressLandblock(uint landblockId) => + _suppressedPrefixes.Add(landblockId & 0xFFFF0000u); + + internal bool Advance() + { + switch (_phase) + { + case 0: + if (_landblockIndex < _landblockSlotLimit) + { + uint id = _source.LandblockSlots[_landblockIndex++]; + if (id != 0u + && (id & 0xFFFF0000u) != _targetPrefix + && !_suppressedPrefixes.Contains( + id & 0xFFFF0000u) + && _source.Landblocks.TryGetValue( + id, + out LandblockPhysics? landblock)) + { + StagingEngine.InstallLandblockClone(id, landblock); + } + WorkUnits++; + return false; + } + _phase++; + return false; + case 1: + _cells ??= _source.CellStruct.GetEnumerator(); + if (CopyOneOutsideTarget(_cells, _destination.CellStruct)) + return CountOne(); + DisposeEnumerator(ref _cells); + _phase++; + return false; + case 2: + _flatCells ??= _source.FlatCellStruct.GetEnumerator(); + if (CopyOneOutsideTarget(_flatCells, _destination.FlatCellStruct)) + return CountOne(); + DisposeEnumerator(ref _flatCells); + _phase++; + return false; + case 3: + _flatEnvCells ??= _source.FlatEnvCell.GetEnumerator(); + if (CopyOneOutsideTarget(_flatEnvCells, _destination.FlatEnvCell)) + return CountOne(); + DisposeEnumerator(ref _flatEnvCells); + _phase++; + return false; + case 4: + _buildings ??= _source.Buildings.GetEnumerator(); + if (CopyOneOutsideTarget(_buildings, _destination.Buildings)) + return CountOne(); + DisposeEnumerator(ref _buildings); + _phase++; + return false; + case 5: + _envCells ??= _source.EnvCells.GetEnumerator(); + if (CopyOneOutsideTarget(_envCells, _destination.EnvCells)) + return CountOne(); + DisposeEnumerator(ref _envCells); + _phase++; + return false; + case 6: + _terrain ??= _source.Terrain.GetEnumerator(); + if (CopyOneOutsideTarget(_terrain, _destination.Terrain)) + return CountOne(); + DisposeEnumerator(ref _terrain); + _phase++; + return false; + case 7: + _outdoorCells ??= _source.OutdoorCells.GetEnumerator(); + if (CopyOneOutsideTarget(_outdoorCells, _destination.OutdoorCells)) + return CountOne(); + DisposeEnumerator(ref _outdoorCells); + _phase++; + return false; + case 8: + if (_ownerIndex < _ownerSlotLimit) + { + uint ownerId = _source.ShadowOwnerSlots[_ownerIndex++]; + if (ownerId != 0u + && !_sourceShadows.IsStaticOwnerRootedIn( + ownerId, + _targetPrefix) + && !IsSuppressedStaticOwner(ownerId) + && !StagingEngine.ShadowObjects.HasLogicalOwner( + ownerId)) + { + StagingEngine.ShadowObjects.MirrorOwnerFrom( + _sourceShadows, + ownerId); + } + WorkUnits++; + return false; + } + uint currentCellId = _active.DataCache?.CellGraph.CurrCell?.Id ?? 0u; + StagingCache.CellGraph.CurrCell = + StagingCache.CellGraph.GetVisible(currentCellId); + _phase++; + return true; + default: + return true; + } + } + + private bool CountOne() + { + WorkUnits++; + return false; + } + + private bool CopyOneOutsideTarget( + IEnumerator> source, + IDictionary destination) + { + if (!source.MoveNext()) + return false; + KeyValuePair pair = source.Current; + uint prefix = pair.Key & 0xFFFF0000u; + if (prefix != _targetPrefix + && !_suppressedPrefixes.Contains(prefix)) + destination[pair.Key] = pair.Value; + return true; + } + + private bool IsSuppressedStaticOwner(uint ownerId) + => _sourceShadows.TryGetStaticOwnerRootPrefix( + ownerId, + out uint prefix) + && _suppressedPrefixes.Contains(prefix); + + private static void DisposeEnumerator( + ref IEnumerator>? enumerator) + { + enumerator?.Dispose(); + enumerator = null; + } + + public void Dispose() + { + DisposeEnumerator(ref _cells); + DisposeEnumerator(ref _flatCells); + DisposeEnumerator(ref _flatEnvCells); + DisposeEnumerator(ref _buildings); + DisposeEnumerator(ref _envCells); + DisposeEnumerator(ref _terrain); + DisposeEnumerator(ref _outdoorCells); + } + } + + private void InstallLandblockClone( + uint landblockId, + LandblockPhysics landblock) + { + _landblocks[landblockId] = landblock; + EnsureLandblockSlot(landblockId); + } + + private void EnsureLandblockSlot(uint landblockId) + { + if (_landblockIndices.ContainsKey(landblockId)) + return; + if (_landblockFreeSlots.TryPop(out int freeIndex)) + { + _landblockSlots[freeIndex] = landblockId; + _landblockIndices[landblockId] = freeIndex; + return; + } + _landblockIndices[landblockId] = _landblockSlots.Count; + _landblockSlots.Add(landblockId); + } + + private void RemoveLandblockSlot(uint landblockId) + { + if (!_landblockIndices.Remove(landblockId, out int slotIndex)) + return; + _landblockSlots[slotIndex] = 0u; + _landblockFreeSlots.Push(slotIndex); } internal sealed class PreparedPhysicsEngineLandblock @@ -232,17 +997,20 @@ public sealed class PhysicsEngine internal PreparedPhysicsEngineLandblock( uint landblockId, LandblockPhysics landblock, + PhysicsEngine staging, PreparedPhysicsDataCacheLandblock dataCache, ShadowObjectRegistry.PreparedLandblockShadowReplacement shadows) { LandblockId = landblockId; Landblock = landblock; + Staging = staging; DataCache = dataCache; Shadows = shadows; } internal uint LandblockId { get; } internal LandblockPhysics Landblock { get; } + internal PhysicsEngine Staging { get; } internal PreparedPhysicsDataCacheLandblock DataCache { get; } internal ShadowObjectRegistry.PreparedLandblockShadowReplacement Shadows { get; } } @@ -251,6 +1019,7 @@ public sealed class PhysicsEngine { private readonly uint _landblockId; private readonly LandblockPhysics _landblock; + private readonly PhysicsEngine _staging; private readonly PhysicsDataCache.LandblockReplacementBuilder _data; private readonly ShadowObjectRegistry.LandblockReplacementBuilder _shadows; private int _phase; @@ -258,19 +1027,23 @@ public sealed class PhysicsEngine internal LandblockReplacementBuilder( uint landblockId, LandblockPhysics landblock, + PhysicsEngine staging, PhysicsDataCache.LandblockReplacementBuilder data, ShadowObjectRegistry.LandblockReplacementBuilder shadows) { _landblockId = landblockId; _landblock = landblock; + _staging = staging; _data = data; _shadows = shadows; } internal int WorkUnits => _data.WorkUnits + _shadows.WorkUnits; - internal bool IsStable => _shadows.IsStable; internal PreparedPhysicsEngineLandblock? Prepared { get; private set; } + internal void RefreshRetainedOwner(uint ownerId) => + _shadows.RefreshOwner(ownerId); + internal bool Advance() { if (_phase == 0) @@ -284,12 +1057,13 @@ public sealed class PhysicsEngine { if (!_shadows.Advance()) return false; - if (IsStable && _data.Prepared is not null + if (_data.Prepared is not null && _shadows.Prepared is not null) { - Prepared = new PreparedPhysicsEngineLandblock( - _landblockId, - _landblock, + Prepared = new PreparedPhysicsEngineLandblock( + _landblockId, + _landblock, + _staging, _data.Prepared, _shadows.Prepared); } @@ -314,6 +1088,7 @@ public sealed class PhysicsEngine float worldOffsetX, float worldOffsetY) { _landblocks[landblockId] = new LandblockPhysics(terrain, cells, portals, worldOffsetX, worldOffsetY); + EnsureLandblockSlot(landblockId); // UCG Stage 1: mirror terrain into the unified graph (inert this stage). DataCache?.CellGraph.RegisterTerrain(landblockId, terrain, new Vector3(worldOffsetX, worldOffsetY, 0f)); @@ -325,6 +1100,7 @@ public sealed class PhysicsEngine public void RemoveLandblock(uint landblockId) { _landblocks.Remove(landblockId); + RemoveLandblockSlot(landblockId); ShadowObjects.DeregisterStaticOwnersForLandblock(landblockId); ShadowObjects.RemoveLandblock(landblockId); DataCache?.RemoveCellsForLandblock(landblockId); // D8: rebase cell BSP transforms on next apply diff --git a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs index 2c2d6fdc..d79e14c5 100644 --- a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs +++ b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs @@ -26,20 +26,26 @@ namespace AcDream.Core.Physics; /// public sealed class ShadowObjectRegistry { - private readonly Dictionary> _cells = new(); - private readonly Dictionary> _entityToCells = new(); // for deregistration - private readonly HashSet _suspendedEntities = new(); + private CollisionWorldStateSlot _collisionWorld; + private Dictionary> _cells => + _collisionWorld.Current.ShadowCells; + private Dictionary> _entityToCells => + _collisionWorld.Current.ShadowEntityCells; // for deregistration + private HashSet _suspendedEntities => + _collisionWorld.Current.SuspendedShadowEntities; // Rows withdrawn because a touched landblock streamed out. The owner may // be seeded in an adjacent still-resident landblock, so its remaining rows // cannot by themselves tell RefloodLandblock that this prefix needs repair. - private readonly Dictionary> _withdrawnPrefixesByOwner = new(); + private Dictionary> _withdrawnPrefixesByOwner => + _collisionWorld.Current.WithdrawnPrefixesByOwner; /// /// A6.P4 door fix (2026-05-24): per-entity original shape list, used by /// to recompose part world-transforms when /// the entity moves. Cleared by . /// - private readonly Dictionary> _entityShapes = new(); + private Dictionary> _entityShapes => + _collisionWorld.Current.ShadowEntityShapes; /// /// BR-7: per-entity registration arguments, kept so a registration can be @@ -50,9 +56,50 @@ public sealed class ShadowObjectRegistry /// gets its cell set recomputed afterwards. /// is the streaming-side trigger. /// - private readonly Dictionary _entityReg = new(); - private readonly Dictionary _ownerVersions = new(); - private ulong _mutationVersion; + private Dictionary _entityReg => + _collisionWorld.Current.ShadowEntityRegistrations; + private Dictionary _ownerVersions => + _collisionWorld.Current.ShadowOwnerVersions; + private Dictionary> _ownerPrefixes => + _collisionWorld.Current.ShadowOwnerPrefixes; + private Dictionary> _prefixOwnerSlots => + _collisionWorld.Current.ShadowPrefixOwnerSlots; + private Dictionary> _prefixOwnerIndices => + _collisionWorld.Current.ShadowPrefixOwnerIndices; + private Dictionary> _prefixFreeSlots => + _collisionWorld.Current.ShadowPrefixFreeSlots; + private List _ownerSlots => + _collisionWorld.Current.ShadowOwnerSlots; + private Dictionary _ownerIndices => + _collisionWorld.Current.ShadowOwnerIndices; + private Stack _ownerFreeSlots => + _collisionWorld.Current.ShadowOwnerFreeSlots; + private readonly HashSet _prefixScratch = new(); + private readonly List _removedPrefixScratch = new(); + internal event Action? OwnerMutated; + internal event Action? OwnerPrefixMembershipChanged; + + public ShadowObjectRegistry() + : this(new CollisionWorldStateSlot()) + { + } + + internal ShadowObjectRegistry(CollisionWorldStateSlot collisionWorld) + { + _collisionWorld = collisionWorld + ?? throw new ArgumentNullException(nameof(collisionWorld)); + } + + internal void AttachCollisionWorld(CollisionWorldStateSlot collisionWorld) + { + ArgumentNullException.ThrowIfNull(collisionWorld); + if (_cells.Count != 0 || _entityReg.Count != 0) + { + throw new InvalidOperationException( + "A populated shadow registry cannot change collision roots."); + } + _collisionWorld = collisionWorld; + } internal sealed record RegistrationRecord( uint SeedCellId, @@ -76,31 +123,38 @@ public sealed class ShadowObjectRegistry private void BumpOwnerVersion(uint entityId) { - _ownerVersions[entityId] = checked(GetOwnerVersion(entityId) + 1UL); - _mutationVersion = checked(_mutationVersion + 1UL); + ulong version = checked(GetOwnerVersion(entityId) + 1UL); + _ownerVersions[entityId] = version; + RefreshOwnerPrefixIndex(entityId); + OwnerMutated?.Invoke(entityId, version); } - internal ulong MutationVersion => _mutationVersion; - internal RetainedRefloodOwnerScan CreateRetainedRefloodOwnerScan( - uint landblockId) => new(this, landblockId & 0xFFFF0000u); + uint landblockId) + { + uint prefix = landblockId & 0xFFFF0000u; + _prefixOwnerSlots.TryGetValue(prefix, out List? slots); + return new RetainedRefloodOwnerScan(this, prefix, slots); + } internal sealed class RetainedRefloodOwnerScan : IDisposable { private readonly ShadowObjectRegistry _owner; private readonly uint _prefix; - private readonly ulong _sourceMutationVersion; - private Dictionary.Enumerator _enumerator; + private readonly List? _slots; + private readonly int _limit; + private int _index; private bool _completed; internal RetainedRefloodOwnerScan( ShadowObjectRegistry owner, - uint prefix) + uint prefix, + List? slots) { _owner = owner; _prefix = prefix; - _sourceMutationVersion = owner.MutationVersion; - _enumerator = owner._entityReg.GetEnumerator(); + _slots = slots; + _limit = slots?.Count ?? 0; } internal RetainedRefloodOwnerScanStep Advance() @@ -109,55 +163,169 @@ public sealed class ShadowObjectRegistry { return new RetainedRefloodOwnerScanStep( Completed: true, - Stable: _owner.MutationVersion == _sourceMutationVersion, HasOwner: false, - OwnerId: 0u, - SourceMutationVersion: _sourceMutationVersion); + OwnerId: 0u); } - if (_owner.MutationVersion != _sourceMutationVersion) + if (_slots is null || _index >= _limit) { _completed = true; return new RetainedRefloodOwnerScanStep( Completed: true, - Stable: false, HasOwner: false, - OwnerId: 0u, - SourceMutationVersion: _sourceMutationVersion); - } - if (!_enumerator.MoveNext()) - { - _completed = true; - return new RetainedRefloodOwnerScanStep( - Completed: true, - Stable: true, - HasOwner: false, - OwnerId: 0u, - SourceMutationVersion: _sourceMutationVersion); + OwnerId: 0u); } - (uint ownerId, RegistrationRecord registration) = - _enumerator.Current; - bool retained = !_owner._suspendedEntities.Contains(ownerId) - && (!registration.IsStatic - || (registration.SeedCellId & 0xFFFF0000u) != _prefix) - && _owner.OwnerTouchesLandblock(ownerId, _prefix); + uint ownerId = _slots[_index++]; + bool retained = _owner.IsRetainedRefloodOwner(ownerId, _prefix); return new RetainedRefloodOwnerScanStep( Completed: false, - Stable: true, HasOwner: retained, - OwnerId: retained ? ownerId : 0u, - SourceMutationVersion: _sourceMutationVersion); + OwnerId: retained ? ownerId : 0u); } - public void Dispose() => _enumerator.Dispose(); + public void Dispose() { } + } + + private void RefreshOwnerPrefixIndex(uint entityId) + { + if (!_entityReg.ContainsKey(entityId)) + { + RemoveOwnerPrefixMembership(entityId); + return; + } + EnsureOwnerSlot(entityId); + _prefixScratch.Clear(); + if (_entityReg.TryGetValue(entityId, out RegistrationRecord? registration)) + _prefixScratch.Add(registration.SeedCellId & 0xFFFF0000u); + if (_entityToCells.TryGetValue(entityId, out List? cells)) + { + for (int index = 0; index < cells.Count; index++) + _prefixScratch.Add(cells[index] & 0xFFFF0000u); + } + if (_withdrawnPrefixesByOwner.TryGetValue( + entityId, + out HashSet? withdrawn)) + { + foreach (uint prefix in withdrawn) + _prefixScratch.Add(prefix & 0xFFFF0000u); + } + + if (!_ownerPrefixes.TryGetValue(entityId, out HashSet? current)) + { + current = new HashSet(); + _ownerPrefixes[entityId] = current; + } + + _removedPrefixScratch.Clear(); + foreach (uint prefix in current) + { + if (!_prefixScratch.Contains(prefix)) + _removedPrefixScratch.Add(prefix); + } + for (int index = 0; index < _removedPrefixScratch.Count; index++) + { + uint prefix = _removedPrefixScratch[index]; + current.Remove(prefix); + if (_prefixOwnerIndices.TryGetValue( + prefix, + out Dictionary? indices) + && indices.Remove(entityId, out int slotIndex)) + { + _prefixOwnerSlots[prefix][slotIndex] = 0u; + _prefixFreeSlots[prefix].Push(slotIndex); + ReleaseEmptyPrefixContainer(prefix, indices); + } + OwnerPrefixMembershipChanged?.Invoke(entityId, prefix); + } + + foreach (uint prefix in _prefixScratch) + { + if (!current.Add(prefix)) + continue; + if (!_prefixOwnerSlots.TryGetValue(prefix, out List? slots)) + { + slots = new List(); + _prefixOwnerSlots[prefix] = slots; + _prefixOwnerIndices[prefix] = new Dictionary(); + _prefixFreeSlots[prefix] = new Stack(); + } + Dictionary indices = _prefixOwnerIndices[prefix]; + if (indices.ContainsKey(entityId)) + continue; + Stack free = _prefixFreeSlots[prefix]; + if (free.TryPop(out int freeIndex)) + { + slots[freeIndex] = entityId; + indices[entityId] = freeIndex; + } + else + { + indices[entityId] = slots.Count; + slots.Add(entityId); + } + OwnerPrefixMembershipChanged?.Invoke(entityId, prefix); + } + + } + + private void RemoveOwnerPrefixMembership(uint entityId) + { + if (_ownerPrefixes.Remove(entityId, out HashSet? prefixes)) + { + foreach (uint prefix in prefixes) + { + if (!_prefixOwnerIndices.TryGetValue( + prefix, + out Dictionary? indices) + || !indices.Remove(entityId, out int slotIndex)) + { + continue; + } + + _prefixOwnerSlots[prefix][slotIndex] = 0u; + _prefixFreeSlots[prefix].Push(slotIndex); + ReleaseEmptyPrefixContainer(prefix, indices); + OwnerPrefixMembershipChanged?.Invoke(entityId, prefix); + } + } + if (_ownerIndices.Remove(entityId, out int ownerSlot)) + { + _ownerSlots[ownerSlot] = 0u; + _ownerFreeSlots.Push(ownerSlot); + } + } + + private void EnsureOwnerSlot(uint entityId) + { + if (_ownerIndices.ContainsKey(entityId)) + return; + if (_ownerFreeSlots.TryPop(out int freeIndex)) + { + _ownerSlots[freeIndex] = entityId; + _ownerIndices[entityId] = freeIndex; + return; + } + _ownerIndices[entityId] = _ownerSlots.Count; + _ownerSlots.Add(entityId); + } + + private void ReleaseEmptyPrefixContainer( + uint prefix, + Dictionary indices) + { + if (indices.Count != 0) + return; + // An in-flight scan retains its captured List reference and observes + // only tombstones. A future owner gets a fresh compact container. + _prefixOwnerSlots.Remove(prefix); + _prefixOwnerIndices.Remove(prefix); + _prefixFreeSlots.Remove(prefix); } internal readonly record struct RetainedRefloodOwnerScanStep( bool Completed, - bool Stable, bool HasOwner, - uint OwnerId, - ulong SourceMutationVersion); + uint OwnerId); /// /// The flood's data source (cells, buildings, terrain origins). Wired by @@ -193,7 +361,8 @@ public sealed class ShadowObjectRegistry uint state = 0u, EntityCollisionFlags flags = EntityCollisionFlags.None, uint seedCellId = 0u, - bool isStatic = true) + bool isStatic = true, + bool publishMutation = true) { // Flood FIRST: retail keeps the previous shadows when the new cell // array would be empty (SetPositionInternal num_cells gate, @@ -211,7 +380,7 @@ public sealed class ShadowObjectRegistry FloodCache, seed, spheres, spheres.Length, isStatic); if (cellSet.Count == 0) return; - Deregister(entityId); + DeregisterCore(entityId, publishMutation: false); var entry = new ShadowEntry(entityId, gfxObjId, worldPos, rotation, radius, collisionType, cylHeight, scale, state, flags); @@ -227,7 +396,10 @@ public sealed class ShadowObjectRegistry _entityReg[entityId] = new RegistrationRecord( seed, worldPos, rotation, state, flags, isStatic, IsMultiPart: false, gfxObjId, radius, collisionType, cylHeight, scale); - BumpOwnerVersion(entityId); + if (publishMutation) + BumpOwnerVersion(entityId); + else + RefreshOwnerPrefixIndex(entityId); } /// @@ -256,7 +428,8 @@ public sealed class ShadowObjectRegistry EntityCollisionFlags flags, float worldOffsetX, float worldOffsetY, uint landblockId, uint seedCellId = 0u, - bool isStatic = false) + bool isStatic = false, + bool publishMutation = true) { if (shapes.Count == 0) { Deregister(entityId); return; } @@ -271,7 +444,7 @@ public sealed class ShadowObjectRegistry FloodCache, seed, floodSpheres, floodSpheres.Count, isStatic); if (cellSet.Count == 0) return; - Deregister(entityId); + DeregisterCore(entityId, publishMutation: false); _entityShapes[entityId] = shapes; var allCells = new List(cellSet.Count); @@ -307,7 +480,10 @@ public sealed class ShadowObjectRegistry seed, entityWorldPos, entityWorldRot, state, flags, isStatic, IsMultiPart: true, GfxObjId: 0u, Radius: 0f, CollisionType: ShadowCollisionType.BSP, CylHeight: 0f, Scale: 1f); - BumpOwnerVersion(entityId); + if (publishMutation) + BumpOwnerVersion(entityId); + else + RefreshOwnerPrefixIndex(entityId); } /// @@ -635,7 +811,8 @@ public sealed class ShadowObjectRegistry 0f, lbPrefix, reg.SeedCellId, - reg.IsStatic); + reg.IsStatic, + publishMutation: false); } else { @@ -654,7 +831,8 @@ public sealed class ShadowObjectRegistry reg.State, reg.Flags, reg.SeedCellId, - reg.IsStatic); + reg.IsStatic, + publishMutation: false); } // Register is also the authoritative movement/replacement API and @@ -674,6 +852,7 @@ public sealed class ShadowObjectRegistry if (withdrawn.Count == 0) _withdrawnPrefixesByOwner.Remove(entityId); } + BumpOwnerVersion(entityId); } /// @@ -711,12 +890,16 @@ public sealed class ShadowObjectRegistry if (retained) { _entityReg[entityId] = retainedRegistration! with { State = newState }; - BumpOwnerVersion(entityId); } if (!_entityToCells.TryGetValue(entityId, out var cellIds)) + { + if (retained) + BumpOwnerVersion(entityId); return; // not registered — no-op + } + foreach (var cellId in cellIds) { if (!_cells.TryGetValue(cellId, out var list)) continue; @@ -727,10 +910,16 @@ public sealed class ShadowObjectRegistry } } + if (retained) + BumpOwnerVersion(entityId); + } /// Remove an entity from all cells it was registered in. public void Deregister(uint entityId) + => DeregisterCore(entityId, publishMutation: true); + + private void DeregisterCore(uint entityId, bool publishMutation) { bool existed = _entityReg.ContainsKey(entityId) || _entityToCells.ContainsKey(entityId) @@ -749,8 +938,12 @@ public sealed class ShadowObjectRegistry _entityReg.Remove(entityId); _suspendedEntities.Remove(entityId); _withdrawnPrefixesByOwner.Remove(entityId); - if (existed) + if (existed && publishMutation) + { BumpOwnerVersion(entityId); + RemoveOwnerPrefixMembership(entityId); + _ownerVersions.Remove(entityId); + } } private static void RemoveOwnerRows( @@ -879,6 +1072,59 @@ public sealed class ShadowObjectRegistry BumpOwnerVersion(entityId); } + /// + /// Retires one logical owner's rows from a streamed-out prefix. This is + /// the owner-granular form used by the collision-generation retirement + /// cursor; it preserves the same static/dynamic lifetime rules as + /// without scanning the complete registry. + /// + internal void RetireOwnerFromLandblock(uint entityId, uint landblockId) + { + uint prefix = landblockId & 0xFFFF0000u; + if (_entityReg.TryGetValue( + entityId, + out RegistrationRecord? registration) + && registration.IsStatic + && (registration.SeedCellId & 0xFFFF0000u) == prefix) + { + DeregisterCore(entityId, publishMutation: false); + RemoveOwnerPrefixMembership(entityId); + _ownerVersions.Remove(entityId); + return; + } + if (!_entityToCells.TryGetValue(entityId, out List? cells)) + return; + + bool touched = false; + for (int index = cells.Count - 1; index >= 0; index--) + { + uint cellId = cells[index]; + if ((cellId & 0xFFFF0000u) != prefix) + continue; + touched = true; + cells.RemoveAt(index); + if (_cells.TryGetValue(cellId, out List? entries)) + { + RemoveOwnerRows(entries, entityId); + if (entries.Count == 0) + _cells.Remove(cellId); + } + } + if (!touched) + return; + if (!_withdrawnPrefixesByOwner.TryGetValue( + entityId, + out HashSet? withdrawn)) + { + withdrawn = new HashSet(); + _withdrawnPrefixesByOwner[entityId] = withdrawn; + } + withdrawn.Add(prefix); + if (cells.Count == 0) + _entityToCells.Remove(entityId); + BumpOwnerVersion(entityId); + } + /// /// All objects registered in a specific cell — retail /// CObjCell::find_obj_collisions iterating only @@ -925,44 +1171,35 @@ public sealed class ShadowObjectRegistry (cell & 0xFFFF0000u) == (landblockId & 0xFFFF0000u)); /// - /// Copies the committed registry into an off-side collision generation. - /// All mutable lists and sets are cloned; immutable registration and shape - /// payloads may be shared. + /// Mirrors one ordinary active-world mutation into an off-side generation. + /// Target-prefix owners may subsequently be reflooded against the staged + /// topology; unrelated owners retain these exact active rows. /// - internal void CopyCollisionStateFrom( + internal void MirrorOwnerFrom( ShadowObjectRegistry source, - PhysicsDataCache stagingCache) + uint entityId) { ArgumentNullException.ThrowIfNull(source); - ArgumentNullException.ThrowIfNull(stagingCache); - Clear(); - DataCache = stagingCache; - foreach ((uint cellId, List entries) in source._cells) - _cells[cellId] = new List(entries); - foreach ((uint ownerId, List cells) in source._entityToCells) - _entityToCells[ownerId] = new List(cells); - foreach (uint ownerId in source._suspendedEntities) - _suspendedEntities.Add(ownerId); - foreach ((uint ownerId, HashSet prefixes) in - source._withdrawnPrefixesByOwner) + DeregisterCore(entityId, publishMutation: false); + if (source.TryCaptureOwnerState( + entityId, + out PreparedShadowOwnerState? state) + && state is not null) { - _withdrawnPrefixesByOwner[ownerId] = new HashSet(prefixes); + InstallOwnerState(state); + _ownerVersions[entityId] = source.GetOwnerVersion(entityId); } - foreach ((uint ownerId, IReadOnlyList shapes) in - source._entityShapes) + else { - _entityShapes[ownerId] = shapes; + RemoveOwnerPrefixMembership(entityId); + _ownerVersions.Remove(entityId); } - foreach ((uint ownerId, RegistrationRecord registration) in - source._entityReg) - { - _entityReg[ownerId] = registration; - } - foreach ((uint ownerId, ulong version) in source._ownerVersions) - _ownerVersions[ownerId] = version; - _mutationVersion = source._mutationVersion; } + internal int CaptureOwnerSlotLimit() => _ownerSlots.Count; + + internal uint GetOwnerSlot(int index) => _ownerSlots[index]; + /// /// Refreshes one staging owner from the exact active payload, then floods /// it against the staging generation's complete cell graph. The returned @@ -975,19 +1212,27 @@ public sealed class ShadowObjectRegistry out ulong sourceVersion) { ArgumentNullException.ThrowIfNull(source); - Deregister(entityId); sourceVersion = source.GetOwnerVersion(entityId); if (!source._entityReg.TryGetValue( entityId, out RegistrationRecord? registration) || source._suspendedEntities.Contains(entityId) - || (registration.IsStatic - && (registration.SeedCellId & 0xFFFF0000u) - == (landblockId & 0xFFFF0000u)) || !source.OwnerTouchesLandblock(entityId, landblockId)) { + // A target-local refresh is not a global owner deletion. Preserve + // the exact active rows when the live owner has moved elsewhere. + MirrorOwnerFrom(source, entityId); return false; } + if (registration.IsStatic + && (registration.SeedCellId & 0xFFFF0000u) + == (landblockId & 0xFFFF0000u)) + { + // Target statics come from the staged landblock itself. + return false; + } + + DeregisterCore(entityId, publishMutation: false); if (registration.IsMultiPart && source._entityShapes.TryGetValue( @@ -1005,7 +1250,8 @@ public sealed class ShadowObjectRegistry 0f, landblockId, registration.SeedCellId, - isStatic: registration.IsStatic); + isStatic: registration.IsStatic, + publishMutation: false); } else { @@ -1024,7 +1270,8 @@ public sealed class ShadowObjectRegistry registration.State, registration.Flags, registration.SeedCellId, - isStatic: registration.IsStatic); + isStatic: registration.IsStatic, + publishMutation: false); } if (source._withdrawnPrefixesByOwner.TryGetValue( @@ -1041,28 +1288,21 @@ public sealed class ShadowObjectRegistry if (retainedWithdrawn.Count != 0) _withdrawnPrefixesByOwner[entityId] = retainedWithdrawn; } + RefreshOwnerPrefixIndex(entityId); + _ownerVersions[entityId] = sourceVersion; return true; } internal LandblockReplacementBuilder CreateLandblockReplacementBuilder( ShadowObjectRegistry staging, uint landblockId, - IReadOnlyDictionary expectedRetainedVersions) => new( + IReadOnlyList expectedRetainedOwners) => new( this, staging, landblockId, - expectedRetainedVersions); + expectedRetainedOwners); - internal void CommitLandblockReplacement( - PreparedLandblockShadowReplacement replacement) - { - for (int index = 0; index < replacement.OwnerIds.Count; index++) - Deregister(replacement.OwnerIds[index]); - for (int index = 0; index < replacement.OwnerStates.Count; index++) - InstallOwnerState(replacement.OwnerStates[index]); - } - - private bool OwnerTouchesLandblock(uint entityId, uint landblockId) + internal bool OwnerTouchesLandblock(uint entityId, uint landblockId) { uint prefix = landblockId & 0xFFFF0000u; if (!_entityReg.TryGetValue(entityId, out RegistrationRecord? record)) @@ -1080,6 +1320,43 @@ public sealed class ShadowObjectRegistry && withdrawn.Contains(prefix); } + internal bool IsStaticOwnerRootedIn(uint entityId, uint landblockId) => + _entityReg.TryGetValue(entityId, out RegistrationRecord? registration) + && registration.IsStatic + && (registration.SeedCellId & 0xFFFF0000u) + == (landblockId & 0xFFFF0000u); + + internal bool TryGetStaticOwnerRootPrefix( + uint entityId, + out uint landblockPrefix) + { + if (_entityReg.TryGetValue( + entityId, + out RegistrationRecord? registration) + && registration.IsStatic) + { + landblockPrefix = registration.SeedCellId & 0xFFFF0000u; + return true; + } + landblockPrefix = 0u; + return false; + } + + internal bool HasLogicalOwner(uint entityId) => + _entityReg.ContainsKey(entityId); + + public int PrefixOwnerSlotCapacityForDiagnostics(uint landblockId) => + _prefixOwnerSlots.TryGetValue( + landblockId & 0xFFFF0000u, + out List? slots) + ? slots.Count + : 0; + + public int OwnerVersionCountForDiagnostics => _ownerVersions.Count; + + public int PrefixOwnerContainerCountForDiagnostics => + _prefixOwnerSlots.Count; + private bool TryCaptureOwnerState( uint entityId, out PreparedShadowOwnerState? state) @@ -1148,63 +1425,65 @@ public sealed class ShadowObjectRegistry private readonly ShadowObjectRegistry _active; private readonly ShadowObjectRegistry _staging; private readonly uint _prefix; - private readonly ulong _sourceMutationVersion; + private readonly IReadOnlyList _expected; + private readonly List? _activeSlots; + private readonly List? _stagingSlots; + private readonly int _activeSlotLimit; + private readonly int _stagingSlotLimit; private readonly HashSet _owners = new(); private readonly List _ownerIds = new(); - private readonly List _states = new(); - private IEnumerator>? _expectedEnumerator; - private Dictionary.Enumerator _registrationEnumerator; - private HashSet.Enumerator _ownerEnumerator; + private readonly List _states = new(); + private readonly Dictionary _stateIndex = new(); + private int _expectedIndex; + private int _activeSlotIndex; + private int _stagingSlotIndex; + private int _ownerIndex; private int _phase; internal LandblockReplacementBuilder( ShadowObjectRegistry active, ShadowObjectRegistry staging, uint landblockId, - IReadOnlyDictionary expected) + IReadOnlyList expected) { _active = active; _staging = staging; _prefix = landblockId & 0xFFFF0000u; - _sourceMutationVersion = active.MutationVersion; - _expectedEnumerator = expected.GetEnumerator(); + _expected = expected; + active._prefixOwnerSlots.TryGetValue( + _prefix, + out _activeSlots); + staging._prefixOwnerSlots.TryGetValue( + _prefix, + out _stagingSlots); + _activeSlotLimit = _activeSlots?.Count ?? 0; + _stagingSlotLimit = _stagingSlots?.Count ?? 0; } internal int WorkUnits { get; private set; } - internal bool IsStable => - _active.MutationVersion == _sourceMutationVersion; internal PreparedLandblockShadowReplacement? Prepared { get; private set; } internal bool Advance() { - if (!IsStable) - return true; switch (_phase) { case 0: - if (_expectedEnumerator!.MoveNext()) + if (_expectedIndex < _expected.Count) { - (uint ownerId, ulong version) = _expectedEnumerator.Current; - if (_active.GetOwnerVersion(ownerId) != version - || !_active.IsRetainedRefloodOwner(ownerId, _prefix)) - { - return true; - } - AddOwner(ownerId); + AddOwner(_expected[_expectedIndex++]); WorkUnits++; return false; } - _expectedEnumerator.Dispose(); - _expectedEnumerator = null; - _registrationEnumerator = _active._entityReg.GetEnumerator(); _phase++; return false; case 1: - if (_registrationEnumerator.MoveNext()) + if (_activeSlotIndex < _activeSlotLimit) { - (uint ownerId, RegistrationRecord registration) = - _registrationEnumerator.Current; - if (registration.IsStatic + uint ownerId = _activeSlots![_activeSlotIndex++]; + if (_active._entityReg.TryGetValue( + ownerId, + out RegistrationRecord? registration) + && registration.IsStatic && (registration.SeedCellId & 0xFFFF0000u) == _prefix) { AddOwner(ownerId); @@ -1212,16 +1491,16 @@ public sealed class ShadowObjectRegistry WorkUnits++; return false; } - _registrationEnumerator.Dispose(); - _registrationEnumerator = _staging._entityReg.GetEnumerator(); _phase++; return false; case 2: - if (_registrationEnumerator.MoveNext()) + if (_stagingSlotIndex < _stagingSlotLimit) { - (uint ownerId, RegistrationRecord registration) = - _registrationEnumerator.Current; - if (registration.IsStatic + uint ownerId = _stagingSlots![_stagingSlotIndex++]; + if (_staging._entityReg.TryGetValue( + ownerId, + out RegistrationRecord? registration) + && registration.IsStatic && (registration.SeedCellId & 0xFFFF0000u) == _prefix) { AddOwner(ownerId); @@ -1229,32 +1508,24 @@ public sealed class ShadowObjectRegistry WorkUnits++; return false; } - _registrationEnumerator.Dispose(); - _ownerEnumerator = _owners.GetEnumerator(); _phase++; return false; case 3: - if (_ownerEnumerator.MoveNext()) + if (_ownerIndex < _ownerIds.Count) { - uint ownerId = _ownerEnumerator.Current; - if (_staging.TryCaptureOwnerState( - ownerId, - out PreparedShadowOwnerState? state) - && state is not null) - { - _states.Add(state); - } + uint ownerId = _ownerIds[_ownerIndex++]; + _staging.TryCaptureOwnerState( + ownerId, + out PreparedShadowOwnerState? state); + _stateIndex[ownerId] = _states.Count; + _states.Add(new PreparedShadowOwnerSlot(ownerId, state)); WorkUnits++; return false; } - _ownerEnumerator.Dispose(); - if (IsStable) - { - Prepared = new PreparedLandblockShadowReplacement( - _prefix, - _ownerIds, - _states); - } + Prepared = new PreparedLandblockShadowReplacement( + _prefix, + _ownerIds, + _states); _phase++; return true; default: @@ -1262,20 +1533,34 @@ public sealed class ShadowObjectRegistry } } - private void AddOwner(uint ownerId) + internal void AddOwner(uint ownerId) { if (_owners.Add(ownerId)) _ownerIds.Add(ownerId); } - public void Dispose() + internal void RefreshOwner(uint ownerId) { - _expectedEnumerator?.Dispose(); - if (_phase is 1 or 2) - _registrationEnumerator.Dispose(); - if (_phase == 3) - _ownerEnumerator.Dispose(); + AddOwner(ownerId); + if (_stateIndex.TryGetValue(ownerId, out int index)) + { + _staging.TryCaptureOwnerState( + ownerId, + out PreparedShadowOwnerState? state); + _states[index].State = state; + return; + } + if (_phase > 3) + { + _staging.TryCaptureOwnerState( + ownerId, + out PreparedShadowOwnerState? state); + _stateIndex[ownerId] = _states.Count; + _states.Add(new PreparedShadowOwnerSlot(ownerId, state)); + } } + + public void Dispose() { } } private bool IsRetainedRefloodOwner(uint ownerId, uint landblockId) @@ -1296,7 +1581,7 @@ public sealed class ShadowObjectRegistry internal PreparedLandblockShadowReplacement( uint landblockPrefix, IReadOnlyList ownerIds, - IReadOnlyList ownerStates) + IReadOnlyList ownerStates) { LandblockPrefix = landblockPrefix; OwnerIds = ownerIds; @@ -1305,7 +1590,21 @@ public sealed class ShadowObjectRegistry internal uint LandblockPrefix { get; } internal IReadOnlyList OwnerIds { get; } - internal IReadOnlyList OwnerStates { get; } + internal IReadOnlyList OwnerStates { get; } + } + + internal sealed class PreparedShadowOwnerSlot + { + internal PreparedShadowOwnerSlot( + uint entityId, + PreparedShadowOwnerState? state) + { + EntityId = entityId; + State = state; + } + + internal uint EntityId { get; } + internal PreparedShadowOwnerState? State { get; set; } } internal sealed record PreparedShadowOwnerState( @@ -1334,7 +1633,15 @@ public sealed class ShadowObjectRegistry _entityShapes.Clear(); _entityReg.Clear(); _ownerVersions.Clear(); - _mutationVersion = 0UL; + _ownerPrefixes.Clear(); + _prefixOwnerSlots.Clear(); + _prefixOwnerIndices.Clear(); + _prefixFreeSlots.Clear(); + _ownerSlots.Clear(); + _ownerIndices.Clear(); + _ownerFreeSlots.Clear(); + _prefixScratch.Clear(); + _removedPrefixScratch.Clear(); _fallback = null; } diff --git a/src/AcDream.Core/World/Cells/CellGraph.cs b/src/AcDream.Core/World/Cells/CellGraph.cs index 021c6d46..7b4f05b7 100644 --- a/src/AcDream.Core/World/Cells/CellGraph.cs +++ b/src/AcDream.Core/World/Cells/CellGraph.cs @@ -18,8 +18,24 @@ namespace AcDream.Core.World.Cells; /// public sealed class CellGraph { - private readonly ConcurrentDictionary _envCells = new(); - private readonly ConcurrentDictionary _terrain = new(); + private readonly CollisionWorldStateSlot _collisionWorld; + private ConcurrentDictionary _envCells => + _collisionWorld.Current.EnvCells; + private ConcurrentDictionary _terrain => + _collisionWorld.Current.Terrain; + private ConcurrentDictionary _outdoorCells => + _collisionWorld.Current.OutdoorCells; + + public CellGraph() + : this(new CollisionWorldStateSlot()) + { + } + + internal CellGraph(CollisionWorldStateSlot collisionWorld) + { + _collisionWorld = collisionWorld + ?? throw new ArgumentNullException(nameof(collisionWorld)); + } /// The player's current cell — the render/lighting root. Written ONLY at the /// player chokepoint @@ -34,8 +50,21 @@ public sealed class CellGraph /// Any id in the cell's landblock; masked to (id & 0xFFFF0000). public void RegisterTerrain(uint landblockPrefix, TerrainSurface terrain, Vector3 worldOrigin) - => _terrain[landblockPrefix & 0xFFFF0000u] = - new CellGraphTerrain(terrain, worldOrigin); + { + uint prefix = landblockPrefix & 0xFFFF0000u; + _terrain[prefix] = new CellGraphTerrain(terrain, worldOrigin); + for (uint low = 1u; low <= 0x40u; low++) + { + uint id = prefix | low; + int index = (int)(low - 1u); + _outdoorCells[id] = LandCell.Synthesize( + id, + terrain, + worldOrigin, + index / 8, + index % 8); + } + } /// /// World origin (SW corner) of the landblock containing , @@ -65,6 +94,8 @@ public sealed class CellGraph CurrCell = null; } _terrain.TryRemove(lb, out _); + for (uint low = 1u; low <= 0x40u; low++) + _outdoorCells.TryRemove(lb | low, out _); foreach (var id in new List(_envCells.Keys)) if ((id & 0xFFFF0000u) == lb) _envCells.TryRemove(id, out _); } @@ -95,9 +126,9 @@ public sealed class CellGraph uint low = id & 0xFFFFu; if (low < 1u || low > 0x40u) return null; - if (!_terrain.TryGetValue(id & 0xFFFF0000u, out var t)) return null; - int idx = (int)(low - 1u); - return LandCell.Synthesize(id, t.Terrain, t.Origin, idx / 8, idx % 8); + return _outdoorCells.TryGetValue(id, out ObjCell? cell) + ? cell + : null; } /// @@ -127,63 +158,10 @@ public sealed class CellGraph return null; } - /// - /// Creates an immutable-reference snapshot for collision-generation - /// preparation. EnvCell and TerrainSurface records are immutable after - /// publication, so copying the registries is sufficient; the active graph - /// remains untouched while the staging graph is rebuilt. - /// - internal CellGraph CreateCollisionStagingCopy() - { - var copy = new CellGraph { CurrCell = CurrCell }; - foreach ((uint id, EnvCell cell) in _envCells) - copy._envCells.TryAdd(id, cell); - foreach ((uint id, CellGraphTerrain terrain) in _terrain) - { - copy._terrain.TryAdd(id, terrain); - } - return copy; - } - internal LandblockReplacementBuilder CreateLandblockReplacementBuilder( CellGraph staging, uint landblockId) => new(this, staging, landblockId); - internal void CommitLandblockReplacement( - PreparedCellGraphLandblock replacement) - { - uint currentCellId = CurrCell?.Id ?? 0u; - for (int index = 0; index < replacement.EnvCellIdsToRemove.Count; index++) - _envCells.TryRemove(replacement.EnvCellIdsToRemove[index], out _); - if (replacement.HasTerrain) - { - _terrain[replacement.LandblockPrefix] = replacement.Terrain!; - } - else - { - _terrain.TryRemove(replacement.LandblockPrefix, out _); - } - for (int index = 0; index < replacement.EnvCells.Count; index++) - { - (uint id, EnvCell cell) = replacement.EnvCells[index]; - _envCells[id] = cell; - } - - uint desiredCurrentCellId = - (currentCellId & 0xFFFF0000u) == replacement.LandblockPrefix - ? currentCellId - : currentCellId == 0u - && (replacement.CurrentCellId & 0xFFFF0000u) - == replacement.LandblockPrefix - ? replacement.CurrentCellId - : 0u; - if (desiredCurrentCellId != 0u) - CurrCell = GetVisible(desiredCurrentCellId); - else if ((currentCellId & 0xFFFF0000u) - == replacement.LandblockPrefix) - CurrCell = null; - } - internal sealed class LandblockReplacementBuilder : IDisposable { private readonly CellGraph _active; @@ -249,8 +227,7 @@ public sealed class CellGraph _removeIds, _envCells, hasTerrain, - terrain, - _staging.CurrCell?.Id ?? 0u); + terrain); _phase = 2; } return true; @@ -271,8 +248,7 @@ internal sealed record PreparedCellGraphLandblock( IReadOnlyList EnvCellIdsToRemove, IReadOnlyList> EnvCells, bool HasTerrain, - CellGraphTerrain? Terrain, - uint CurrentCellId); + CellGraphTerrain? Terrain); internal sealed record CellGraphTerrain( TerrainSurface Terrain, diff --git a/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs b/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs index e95dfcae..95ccf9dc 100644 --- a/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs +++ b/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs @@ -78,45 +78,545 @@ public readonly record struct RuntimeCollisionGenerationCommitted( ulong Generation, bool Ready); +/// +/// One process-local, versioned owner-mutation stream shared by every +/// collision draft. A live mutation is appended once; drafts consume only the +/// newest still-relevant record for each owner at their own metered cursor. +/// +internal sealed class CollisionOwnerMutationJournal +{ + private readonly List _entries = new(capacity: 256); + private readonly Dictionary _entryIndexByOwner = new(); + private long _nextSequence = 1; + private long _compactionThreshold; + private int _compactionIndex; + + internal long NextSequence => _nextSequence; + internal int Count => _entries.Count; + internal int ActiveCount => _entryIndexByOwner.Count; + internal bool HasPendingCompaction => _compactionThreshold != 0L; + + internal MutationRecord Record( + uint ownerId, + ulong ownerVersion, + long latestPreparationStartSequence) + { + long sequence = _nextSequence; + _nextSequence = checked(_nextSequence + 1L); + if (_entryIndexByOwner.TryGetValue(ownerId, out int index)) + { + if (_entries[index].Sequence >= latestPreparationStartSequence) + { + _entries[index] = new Entry(sequence, ownerId, ownerVersion); + return new MutationRecord(sequence, index); + } + // At least one newer draft captured the live root after this slot. + // Leave a tombstone for older cursors and append one new coalescing + // slot that every draft created since then can observe. + _entries[index] = default; + } + _entryIndexByOwner[ownerId] = _entries.Count; + _entries.Add(new Entry(sequence, ownerId, ownerVersion)); + return new MutationRecord( + sequence, + _entries.Count - 1); + } + + internal Entry Get(int index) => _entries[index]; + + internal bool TryGet( + uint ownerId, + out Entry entry, + out int slotIndex) + { + if (_entryIndexByOwner.TryGetValue(ownerId, out slotIndex)) + { + entry = _entries[slotIndex]; + return true; + } + entry = default; + slotIndex = -1; + return false; + } + + internal void RequestCompactionBefore(long sequence) + { + if (sequence <= _compactionThreshold) + return; + _compactionThreshold = sequence; + _compactionIndex = 0; + } + + internal bool AdvanceCompaction() + { + if (!HasPendingCompaction) + return false; + if (_compactionIndex < _entries.Count) + { + int index = _compactionIndex++; + Entry entry = _entries[index]; + if (entry.OwnerId != 0u + && entry.Sequence < _compactionThreshold + && _entryIndexByOwner.TryGetValue( + entry.OwnerId, + out int currentIndex) + && currentIndex == index) + { + _entryIndexByOwner.Remove(entry.OwnerId); + _entries[index] = default; + } + return true; + } + if (_entries.Count != 0 && _entries[^1].OwnerId == 0u) + { + _entries.RemoveAt(_entries.Count - 1); + return true; + } + _compactionThreshold = 0L; + _compactionIndex = 0; + return false; + } + + internal void Clear() + { + _entries.Clear(); + _entryIndexByOwner.Clear(); + _compactionThreshold = 0L; + _compactionIndex = 0; + } + + internal readonly record struct Entry( + long Sequence, + uint OwnerId, + ulong OwnerVersion); + + internal readonly record struct MutationRecord( + long Sequence, + int SlotIndex); +} + /// /// One off-side collision generation. It owns a private cache, cell graph, -/// engine, and shadow registry cloned from the previous complete generation. -/// Hosts may populate it incrementally, but only Runtime can activate it. +/// engine, and shadow registry materialized incrementally from an O(1) root +/// snapshot. Hosts may populate it incrementally, but only Runtime can activate +/// it. /// internal sealed class PreparedLandblockCollisionGeneration : IDisposable { + internal const int MaxConcurrentCollisionPreparations = 256; private readonly RuntimePhysicsState _owner; private readonly RuntimeCollisionAdmission _admission; private readonly Dictionary _retainedOwnerVersions = new(); private readonly List _retainedOwnerIds = new(); + private readonly HashSet _retainedOwnerSet = new(); + private readonly HashSet _armedOwners = new(); + private readonly HashSet _pendingOwners = new(); + private readonly Queue _pendingOwnerQueue = new(); + private readonly PhysicsEngine.PreparedPhysicsEngineLandblock?[] + _pendingCommittedRebases = + new PhysicsEngine.PreparedPhysicsEngineLandblock?[ + MaxConcurrentCollisionPreparations]; + private int _pendingCommittedRebaseHead; + private int _pendingCommittedRebaseCount; + private PhysicsEngine.LandblockReplacementApplyCursor? + _activeCommittedRebase; + private readonly Dictionary _retiredLandblocks = new(); + private readonly HashSet _pendingRetirementSet = new(); + private readonly Queue _pendingRetirements = new(); + private PhysicsEngine.LandblockRetirementCursor? _activeRetirement; + private readonly HashSet _pendingCloneOwnerMutations = new(); + private readonly Queue _pendingCloneOwnerMutationQueue = new(); + private readonly HashSet _pendingRoutedOwnerMutations = new(); + private readonly Queue _pendingRoutedOwnerMutationQueue = new(); + private PhysicsEngine.CollisionStagingBuilder? _stagingBuilder; private ShadowObjectRegistry.RetainedRefloodOwnerScan? _retainedOwnerScan; private PhysicsEngine.LandblockReplacementBuilder? _sealBuilder; private PhysicsEngine.PreparedPhysicsEngineLandblock? _sealedReplacement; + private readonly CollisionOwnerMutationJournal _ownerMutationJournal; + private readonly long _ownerMutationStartSequence; + private readonly Dictionary _observedOwnerMutationSequences = new(); + private readonly HashSet _subscribedOwners = new(); + private readonly HashSet _exactSubscribedOwners = new(); + private long _ownerMutationScanEpoch; + private int _ownerMutationScanIndex; + private bool _sealedExactWriteThrough; private bool _disposed; internal PreparedLandblockCollisionGeneration( RuntimePhysicsState owner, RuntimeCollisionAdmission admission, - PhysicsDataCache dataCache, - PhysicsEngine engine) + PhysicsEngine.CollisionStagingBuilder stagingBuilder, + CollisionOwnerMutationJournal ownerMutationJournal, + long sequence) { _owner = owner; _admission = admission; - DataCache = dataCache; - Engine = engine; + _stagingBuilder = stagingBuilder + ?? throw new ArgumentNullException(nameof(stagingBuilder)); + _ownerMutationJournal = ownerMutationJournal + ?? throw new ArgumentNullException(nameof(ownerMutationJournal)); + _ownerMutationStartSequence = ownerMutationJournal.NextSequence; + _ownerMutationScanEpoch = ownerMutationJournal.NextSequence; + _ownerMutationScanIndex = ownerMutationJournal.Count; + DataCache = stagingBuilder.StagingCache; + Engine = stagingBuilder.StagingEngine; + Sequence = sequence; } internal PhysicsDataCache DataCache { get; } internal PhysicsEngine Engine { get; } + internal long Sequence { get; } + internal long OwnerMutationStartSequence => _ownerMutationStartSequence; internal uint[] GfxObjectIds { get; private set; } = Array.Empty(); internal uint[] SetupIds { get; private set; } = Array.Empty(); internal IReadOnlyDictionary RetainedOwnerVersions => _retainedOwnerVersions; internal bool IsDisposed => _disposed; internal bool RetainedOwnerCaptureComplete { get; private set; } - internal ulong RetainedOwnerCaptureMutationVersion { get; private set; } + internal bool StagingCloneComplete { get; private set; } internal bool IsSealed => _sealedReplacement is not null; - internal ulong SealedShadowMutationVersion { get; private set; } + internal bool IsReadyForActivation => + _sealedReplacement is not null + && _pendingOwners.Count == 0 + && _pendingRoutedOwnerMutations.Count == 0; + + internal bool HasPendingCommittedRebase => + _activeCommittedRebase is not null + || _pendingCommittedRebaseCount != 0; + + internal bool HasPendingRetirement => + _activeRetirement is not null + || _pendingRetirements.Count != 0; + + internal bool IsOwnerMutationReconciliationCurrent => + _ownerMutationScanEpoch == _ownerMutationJournal.NextSequence + && _ownerMutationScanIndex >= _ownerMutationJournal.Count; + + internal RuntimeCollisionJournalStep AdvanceOwnerMutationReconciliation() + { + EnsureUsable(); + if (!StagingCloneComplete) + { + throw new InvalidOperationException( + "Collision owner mutations cannot reconcile before staging materialization."); + } + long epoch = _ownerMutationJournal.NextSequence; + // Already-visited slots are subscribed and receive either exact + // target write-through or a coalesced metered replay. + // Continue from the current cursor when the journal epoch advances; + // restarting at zero would let one continuously-moving unrelated + // owner starve every later slot. + _ownerMutationScanEpoch = epoch; + if (_ownerMutationScanIndex < _ownerMutationJournal.Count) + { + CollisionOwnerMutationJournal.Entry entry = + _ownerMutationJournal.Get(_ownerMutationScanIndex++); + if (entry.OwnerId == 0u) + { + return new RuntimeCollisionJournalStep( + Completed: _ownerMutationScanIndex + >= _ownerMutationJournal.Count, + Worked: true); + } + + // Every visited slot gets a cheap notification subscription so a + // later same-prefix mutation can enqueue one metered replay. Only + // target-relevant owners are promoted to exact write-through. + SubscribeOwner(entry.OwnerId, exact: false); + + if (entry.Sequence < _ownerMutationStartSequence + || (_observedOwnerMutationSequences.TryGetValue( + entry.OwnerId, + out long observed) + && observed >= entry.Sequence)) + { + return new RuntimeCollisionJournalStep( + Completed: _ownerMutationScanIndex + >= _ownerMutationJournal.Count, + Worked: true); + } + _observedOwnerMutationSequences[entry.OwnerId] = entry.Sequence; + if (ObserveOwnerMutation(entry.OwnerId)) + SubscribeOwner(entry.OwnerId, exact: true); + return new RuntimeCollisionJournalStep( + Completed: _ownerMutationScanIndex + >= _ownerMutationJournal.Count, + Worked: true); + } + return new RuntimeCollisionJournalStep( + Completed: true, + Worked: false); + } + + internal void ObserveSubscribedOwnerMutation( + uint ownerId, + long sequence, + long epochBefore, + int countBefore) + { + if (_disposed) + return; + bool wasCurrent = _ownerMutationScanEpoch == epochBefore + && _ownerMutationScanIndex >= countBefore; + _observedOwnerMutationSequences[ownerId] = sequence; + if (!_sealedExactWriteThrough + && !_exactSubscribedOwners.Contains(ownerId)) + { + if (_pendingRoutedOwnerMutations.Add(ownerId)) + _pendingRoutedOwnerMutationQueue.Enqueue(ownerId); + return; + } + _ = ObserveOwnerMutation(ownerId); + if (wasCurrent) + { + _ownerMutationScanEpoch = _ownerMutationJournal.NextSequence; + _ownerMutationScanIndex = _ownerMutationJournal.Count; + } + } + + internal void ObserveRoutedOwnerMembershipMutation(uint ownerId) + { + if (_disposed || _exactSubscribedOwners.Contains(ownerId)) + return; + if (_sealedExactWriteThrough) + { + _ = ObserveOwnerMutation(ownerId); + SubscribeOwner(ownerId, exact: true); + return; + } + if (_pendingRoutedOwnerMutations.Add(ownerId)) + _pendingRoutedOwnerMutationQueue.Enqueue(ownerId); + } + + internal RuntimeCollisionJournalStep AdvanceRoutedOwnerMutation() + { + EnsureUsable(); + while (_pendingRoutedOwnerMutationQueue.Count != 0) + { + uint ownerId = _pendingRoutedOwnerMutationQueue.Dequeue(); + if (!_pendingRoutedOwnerMutations.Remove(ownerId)) + continue; + if (ObserveOwnerMutation(ownerId)) + SubscribeOwner(ownerId, exact: true); + if (_ownerMutationJournal.TryGet( + ownerId, + out CollisionOwnerMutationJournal.Entry entry, + out int slotIndex)) + { + _observedOwnerMutationSequences[ownerId] = entry.Sequence; + if (slotIndex == _ownerMutationScanIndex) + { + _ownerMutationScanIndex++; + _ownerMutationScanEpoch = + _ownerMutationJournal.NextSequence; + } + else if (_ownerMutationScanIndex + >= _ownerMutationJournal.Count) + { + // This slot was already behind the cursor. The routed + // prefix transition is the only previously-unsubscribed + // mutation that can make it target-relevant, so observing + // its latest coalesced entry closes the current epoch. + _ownerMutationScanEpoch = + _ownerMutationJournal.NextSequence; + } + } + return new RuntimeCollisionJournalStep( + Completed: _pendingRoutedOwnerMutations.Count == 0, + Worked: true); + } + return new RuntimeCollisionJournalStep( + Completed: true, + Worked: false); + } + + internal RuntimeCollisionPreparationStep AdvanceStagingClone() + { + EnsureUsable(); + if (!StagingCloneComplete) + { + int before = _stagingBuilder!.WorkUnits; + if (!_stagingBuilder.Advance()) + { + return new RuntimeCollisionPreparationStep( + Completed: false, + WorkUnits: _stagingBuilder.WorkUnits - before); + } + _stagingBuilder.Dispose(); + _stagingBuilder = null; + StagingCloneComplete = true; + } + + while (_pendingCloneOwnerMutationQueue.Count != 0) + { + uint ownerId = _pendingCloneOwnerMutationQueue.Dequeue(); + if (!_pendingCloneOwnerMutations.Remove(ownerId)) + continue; + ObserveOwnerMutation(ownerId); + return new RuntimeCollisionPreparationStep( + Completed: false, + WorkUnits: 1); + } + return new RuntimeCollisionPreparationStep( + Completed: true, + WorkUnits: 0); + } + + internal void EnqueueCommittedRebase( + PhysicsEngine.PreparedPhysicsEngineLandblock replacement) + { + EnsureUsable(); + if (_pendingCommittedRebaseCount + == _pendingCommittedRebases.Length) + { + throw new InvalidOperationException( + "Collision preparation rebase capacity was exceeded."); + } + int tail = (_pendingCommittedRebaseHead + + _pendingCommittedRebaseCount) + % _pendingCommittedRebases.Length; + _pendingCommittedRebases[tail] = replacement; + _pendingCommittedRebaseCount++; + } + + internal void RecordDemotion(uint landblockId) + { + EnsureUsable(); + uint canonical = CanonicalLandblock(landblockId); + InvalidateCommittedRebases(canonical); + _stagingBuilder?.SuppressLandblock(canonical); + EnqueueRetirement(canonical, withdraw: false); + } + + internal void RecordWithdrawal(uint landblockId) + { + EnsureUsable(); + uint canonical = CanonicalLandblock(landblockId); + InvalidateCommittedRebases(canonical); + _stagingBuilder?.SuppressLandblock(canonical); + EnqueueRetirement(canonical, withdraw: true); + } + + internal PhysicsEngine.LandblockRetirementStep AdvanceRetirement() + { + EnsureUsable(); + if (_activeRetirement is null) + { + uint canonical = DequeueRetirement(); + _activeRetirement = Engine.CreateLandblockRetirementCursor( + _owner.Engine, + canonical, + _retiredLandblocks[canonical]); + } + PhysicsEngine.LandblockRetirementStep step = + _activeRetirement.Advance(); + if (step.Completed) + { + _activeRetirement.Dispose(); + _activeRetirement = null; + } + return step; + } + + internal PhysicsEngine.LandblockReplacementApplyStep + AdvanceCommittedRebase() + { + EnsureUsable(); + if (_activeCommittedRebase is null) + { + PhysicsEngine.PreparedPhysicsEngineLandblock replacement = + DequeueCommittedRebase(); + if (_retiredLandblocks.ContainsKey(replacement.LandblockId)) + { + return new PhysicsEngine.LandblockReplacementApplyStep( + Completed: false, + Worked: false, + HasOwner: false, + OwnerId: 0u); + } + _activeCommittedRebase = + Engine.CreateLandblockReplacementApplyCursor(replacement); + } + PhysicsEngine.LandblockReplacementApplyStep step = + _activeCommittedRebase.Advance(); + if (step.HasOwner) + ForceOwnerReflood(step.OwnerId); + if (step.Completed) + { + _activeCommittedRebase.Dispose(); + _activeCommittedRebase = null; + } + return step; + } + + private PhysicsEngine.PreparedPhysicsEngineLandblock + DequeueCommittedRebase() + { + PhysicsEngine.PreparedPhysicsEngineLandblock replacement = + _pendingCommittedRebases[_pendingCommittedRebaseHead] + ?? throw new InvalidOperationException( + "Collision rebase queue contained an empty slot."); + _pendingCommittedRebases[_pendingCommittedRebaseHead] = null; + _pendingCommittedRebaseHead = (_pendingCommittedRebaseHead + 1) + % _pendingCommittedRebases.Length; + _pendingCommittedRebaseCount--; + return replacement; + } + + private void ClearCommittedRebases() + { + while (_pendingCommittedRebaseCount != 0) + _ = DequeueCommittedRebase(); + _pendingCommittedRebaseHead = 0; + } + + private void InvalidateCommittedRebases(uint landblockId) + { + uint canonical = CanonicalLandblock(landblockId); + if (_activeCommittedRebase?.LandblockId == canonical) + { + _activeCommittedRebase.Dispose(); + _activeCommittedRebase = null; + } + + // Queued entries are left in their fixed ring and skipped one per + // later seal step. This keeps retirement admission O(1). + } + + private void EnqueueRetirement(uint canonical, bool withdraw) + { + bool changed = !_retiredLandblocks.TryGetValue( + canonical, + out bool previousWithdraw) + || (withdraw && !previousWithdraw); + _retiredLandblocks[canonical] = previousWithdraw || withdraw; + if (!changed) + return; + if (_activeRetirement?.LandblockId == canonical) + { + _activeRetirement.Dispose(); + _activeRetirement = null; + } + if (!_pendingRetirementSet.Add(canonical)) + return; + _pendingRetirements.Enqueue(canonical); + } + + private uint DequeueRetirement() + { + uint canonical = _pendingRetirements.Dequeue(); + _pendingRetirementSet.Remove(canonical); + return canonical; + } + + private void ClearRetirements() + { + _pendingRetirements.Clear(); + _activeRetirement?.Dispose(); + _activeRetirement = null; + _retiredLandblocks.Clear(); + _pendingRetirementSet.Clear(); + } internal bool Matches( RuntimePhysicsState owner, @@ -134,15 +634,85 @@ internal sealed class PreparedLandblockCollisionGeneration : IDisposable internal void RefreshRetainedOwner(uint ownerId) { EnsureUsable(); - bool retained = Engine.ShadowObjects.RefreshRetainedOwnerFrom( + EnsureRetainedOwner(ownerId); + _ = Engine.ShadowObjects.RefreshRetainedOwnerFrom( _owner.Engine.ShadowObjects, ownerId, _admission.LandblockId, out ulong version); - if (retained) - _retainedOwnerVersions[ownerId] = version; - else - _retainedOwnerVersions.Remove(ownerId); + _retainedOwnerVersions[ownerId] = version; + _pendingOwners.Remove(ownerId); + _armedOwners.Add(ownerId); + _sealBuilder?.RefreshRetainedOwner(ownerId); + SubscribeOwner(ownerId); + } + + internal bool ObserveOwnerMutation(uint ownerId) + { + if (_disposed) + return false; + if (!StagingCloneComplete) + { + if (_pendingCloneOwnerMutations.Add(ownerId)) + _pendingCloneOwnerMutationQueue.Enqueue(ownerId); + return false; + } + if (_owner.Engine.ShadowObjects.IsStaticOwnerRootedIn( + ownerId, + _admission.LandblockId) + || Engine.ShadowObjects.IsStaticOwnerRootedIn( + ownerId, + _admission.LandblockId)) + { + // The staged build is authoritative for target-root statics. Never + // mirror the outgoing generation back over an omitted/replaced + // authored owner merely because its old live state changed. + return false; + } + bool relevantBefore = Engine.ShadowObjects.OwnerTouchesLandblock( + ownerId, + _admission.LandblockId); + Engine.ShadowObjects.MirrorOwnerFrom( + _owner.Engine.ShadowObjects, + ownerId); + bool relevant = _armedOwners.Contains(ownerId) + || relevantBefore + || _owner.Engine.ShadowObjects.OwnerTouchesLandblock( + ownerId, + _admission.LandblockId); + if (!relevant) + return false; + + EnsureRetainedOwner(ownerId); + RefreshRetainedOwner(ownerId); + return true; + } + + private void ForceOwnerReflood(uint ownerId) + { + if (_disposed) + return; + if (!StagingCloneComplete) + { + if (_pendingCloneOwnerMutations.Add(ownerId)) + _pendingCloneOwnerMutationQueue.Enqueue(ownerId); + return; + } + if (_owner.Engine.ShadowObjects.IsStaticOwnerRootedIn( + ownerId, + _admission.LandblockId) + || Engine.ShadowObjects.IsStaticOwnerRootedIn( + ownerId, + _admission.LandblockId)) + { + return; + } + + Engine.ShadowObjects.MirrorOwnerFrom( + _owner.Engine.ShadowObjects, + ownerId); + EnsureRetainedOwner(ownerId); + RefreshRetainedOwner(ownerId); } internal RuntimeCollisionOwnerCaptureStep AdvanceRetainedOwnerCapture() @@ -160,22 +730,12 @@ internal sealed class PreparedLandblockCollisionGeneration : IDisposable .CreateRetainedRefloodOwnerScan(_admission.LandblockId); ShadowObjectRegistry.RetainedRefloodOwnerScanStep step = _retainedOwnerScan.Advance(); - if (step.Completed && !step.Stable) - { - ResetRetainedOwnerCapture(); - return new RuntimeCollisionOwnerCaptureStep( - Completed: false, - Restarted: true, - HasOwner: false, - OwnerId: 0u); - } if (step.HasOwner) - _retainedOwnerIds.Add(step.OwnerId); + EnsureRetainedOwner(step.OwnerId); if (step.Completed) { _retainedOwnerScan.Dispose(); _retainedOwnerScan = null; - RetainedOwnerCaptureMutationVersion = step.SourceMutationVersion; RetainedOwnerCaptureComplete = true; } return new RuntimeCollisionOwnerCaptureStep( @@ -202,16 +762,24 @@ internal sealed class PreparedLandblockCollisionGeneration : IDisposable internal void ResetRetainedOwnerCapture() { EnsureUsable(); + ClearOwnerSubscriptions(); _retainedOwnerScan?.Dispose(); _retainedOwnerScan = null; _retainedOwnerIds.Clear(); + _retainedOwnerSet.Clear(); _retainedOwnerVersions.Clear(); + _armedOwners.Clear(); + _pendingOwners.Clear(); + _pendingOwnerQueue.Clear(); + _pendingCloneOwnerMutations.Clear(); + _pendingCloneOwnerMutationQueue.Clear(); + _pendingRoutedOwnerMutations.Clear(); + _pendingRoutedOwnerMutationQueue.Clear(); + _sealedExactWriteThrough = false; RetainedOwnerCaptureComplete = false; - RetainedOwnerCaptureMutationVersion = 0UL; _sealedReplacement = null; _sealBuilder?.Dispose(); _sealBuilder = null; - SealedShadowMutationVersion = 0UL; } internal RuntimeCollisionSealStep AdvanceSeal() @@ -224,14 +792,23 @@ internal sealed class PreparedLandblockCollisionGeneration : IDisposable Restarted: true, WorkUnits: 0); } - if (_owner.Engine.ShadowObjects.MutationVersion - != RetainedOwnerCaptureMutationVersion) + while (_pendingOwnerQueue.Count != 0) { - ResetRetainedOwnerCapture(); + uint ownerId = _pendingOwnerQueue.Dequeue(); + if (!_pendingOwners.Remove(ownerId)) + continue; + RefreshRetainedOwner(ownerId); + if (_sealedReplacement is not null) + { + return new RuntimeCollisionSealStep( + Completed: _pendingOwners.Count == 0, + Restarted: false, + WorkUnits: 1); + } return new RuntimeCollisionSealStep( Completed: false, - Restarted: true, - WorkUnits: 0); + Restarted: false, + WorkUnits: 1); } if (_retainedOwnerVersions.Count != _retainedOwnerIds.Count) { @@ -244,7 +821,7 @@ internal sealed class PreparedLandblockCollisionGeneration : IDisposable _admission.LandblockId, GfxObjectIds, SetupIds, - _retainedOwnerVersions); + _retainedOwnerIds); int before = _sealBuilder.WorkUnits; bool completed = _sealBuilder.Advance(); int workUnits = _sealBuilder.WorkUnits - before; @@ -255,7 +832,7 @@ internal sealed class PreparedLandblockCollisionGeneration : IDisposable Restarted: false, workUnits); } - if (!_sealBuilder.IsStable || _sealBuilder.Prepared is null) + if (_sealBuilder.Prepared is null) { ResetRetainedOwnerCapture(); return new RuntimeCollisionSealStep( @@ -264,10 +841,11 @@ internal sealed class PreparedLandblockCollisionGeneration : IDisposable workUnits); } _sealedReplacement = _sealBuilder.Prepared; - _sealBuilder.Dispose(); - _sealBuilder = null; - SealedShadowMutationVersion = - _owner.Engine.ShadowObjects.MutationVersion; + // From this point through the same-call activation, every already- + // observed owner mutation writes through exactly. The finite dirty + // queue accumulated during topology construction can now drain even + // when several unrelated owners keep moving every update tick. + _sealedExactWriteThrough = true; return new RuntimeCollisionSealStep( Completed: true, Restarted: false, @@ -285,6 +863,27 @@ internal sealed class PreparedLandblockCollisionGeneration : IDisposable internal void MarkCommitted() { EnsureUsable(); + _sealedExactWriteThrough = false; + _sealBuilder = null; + _sealedReplacement = null; + _retainedOwnerIds.Clear(); + _retainedOwnerSet.Clear(); + _retainedOwnerVersions.Clear(); + _armedOwners.Clear(); + _pendingOwners.Clear(); + _pendingOwnerQueue.Clear(); + ClearCommittedRebases(); + ClearRetirements(); + _activeCommittedRebase?.Dispose(); + _activeCommittedRebase = null; + _pendingCloneOwnerMutations.Clear(); + _pendingCloneOwnerMutationQueue.Clear(); + _pendingRoutedOwnerMutations.Clear(); + _pendingRoutedOwnerMutationQueue.Clear(); + _observedOwnerMutationSequences.Clear(); + ClearOwnerSubscriptions(); + _stagingBuilder?.Dispose(); + _stagingBuilder = null; _disposed = true; } @@ -296,14 +895,55 @@ internal sealed class PreparedLandblockCollisionGeneration : IDisposable _retainedOwnerScan?.Dispose(); _retainedOwnerScan = null; _retainedOwnerIds.Clear(); + _retainedOwnerSet.Clear(); _retainedOwnerVersions.Clear(); + _armedOwners.Clear(); + _pendingOwners.Clear(); + _pendingOwnerQueue.Clear(); + ClearCommittedRebases(); + ClearRetirements(); + _activeCommittedRebase?.Dispose(); + _activeCommittedRebase = null; + _pendingCloneOwnerMutations.Clear(); + _pendingCloneOwnerMutationQueue.Clear(); + _pendingRoutedOwnerMutations.Clear(); + _pendingRoutedOwnerMutationQueue.Clear(); + _observedOwnerMutationSequences.Clear(); + ClearOwnerSubscriptions(); + _stagingBuilder?.Dispose(); + _stagingBuilder = null; _sealBuilder?.Dispose(); _sealBuilder = null; _sealedReplacement = null; - SealedShadowMutationVersion = 0UL; + _sealedExactWriteThrough = false; _disposed = true; } + private void EnsureRetainedOwner(uint ownerId) + { + if (_retainedOwnerSet.Add(ownerId)) + _retainedOwnerIds.Add(ownerId); + } + + private void SubscribeOwner(uint ownerId, bool exact = true) + { + if (exact) + _exactSubscribedOwners.Add(ownerId); + if (_subscribedOwners.Add(ownerId)) + _owner.SubscribeCollisionOwner(ownerId, this); + } + + private void ClearOwnerSubscriptions() + { + foreach (uint ownerId in _subscribedOwners) + _owner.UnsubscribeCollisionOwner(ownerId, this); + _subscribedOwners.Clear(); + _exactSubscribedOwners.Clear(); + } + + private static uint CanonicalLandblock(uint value) => + (value & 0xFFFF0000u) | 0xFFFFu; + private void EnsureUsable() { if (_disposed) @@ -317,6 +957,14 @@ internal readonly record struct RuntimeCollisionOwnerCaptureStep( bool HasOwner, uint OwnerId); +internal readonly record struct RuntimeCollisionPreparationStep( + bool Completed, + int WorkUnits); + +internal readonly record struct RuntimeCollisionJournalStep( + bool Completed, + bool Worked); + internal readonly record struct RuntimeCollisionSealStep( bool Completed, bool Restarted, @@ -340,12 +988,34 @@ public sealed class RuntimePhysicsState : IDisposable private readonly Dictionary _collisionGenerations = new(); private readonly Dictionary _collisionAdmissions = new(); + private readonly Dictionary + _preparedCollisionGenerations = new(); + private readonly CollisionOwnerMutationJournal _collisionOwnerJournal = new(); + private readonly Dictionary> + _collisionOwnerSubscribers = new(); private int _collisionMutationThreadId; + private bool _suppressCollisionOwnerJournal; + private long _nextCollisionPreparationSequence; + private long _latestCollisionPreparationStartSequence; + private readonly List> + _collisionGenerationCommittedObservers = new(); private bool _disposed; public event Action? CellCommitted; public event Action? - CollisionGenerationCommitted; + CollisionGenerationCommitted + { + add + { + if (value is not null) + _collisionGenerationCommittedObservers.Add(value); + } + remove + { + if (value is not null) + _collisionGenerationCommittedObservers.Remove(value); + } + } internal RuntimePhysicsState( RuntimeEntityDirectory entities, @@ -359,6 +1029,9 @@ public sealed class RuntimePhysicsState : IDisposable { DataCache = DataCache, }; + Engine.ShadowObjects.OwnerMutated += OnCollisionOwnerMutated; + Engine.ShadowObjects.OwnerPrefixMembershipChanged += + OnCollisionOwnerPrefixMembershipChanged; } internal RuntimePhysicsState( @@ -369,8 +1042,12 @@ public sealed class RuntimePhysicsState : IDisposable Entities = entities ?? throw new ArgumentNullException(nameof(entities)); _timeProvider = timeProvider ?? TimeProvider.System; Engine = engine ?? throw new ArgumentNullException(nameof(engine)); - DataCache = engine.DataCache ?? PhysicsDataCache.CreateProduction(); + DataCache = engine.DataCache + ?? PhysicsDataCache.CreateProduction(engine.CollisionWorld); Engine.DataCache = DataCache; + Engine.ShadowObjects.OwnerMutated += OnCollisionOwnerMutated; + Engine.ShadowObjects.OwnerPrefixMembershipChanged += + OnCollisionOwnerPrefixMembershipChanged; } internal RuntimeEntityDirectory Entities { get; } @@ -379,6 +1056,8 @@ public sealed class RuntimePhysicsState : IDisposable public int SpatialRootCount => _spatialRoots.Count; public int SpatialRemoteCount => _spatialRemotes.Count; public int SpatialProjectileCount => _spatialProjectiles.Count; + internal int CollisionOwnerJournalEntryCountForDiagnostics => + _collisionOwnerJournal.ActiveCount; internal double UtcNowSeconds => (_timeProvider.GetUtcNow() - DateTimeOffset.UnixEpoch) .TotalSeconds; @@ -1146,14 +1825,40 @@ public sealed class RuntimePhysicsState : IDisposable { ValidateAdmission(admission); EnsureCollisionMutationThread(); - PhysicsDataCache stagingCache = DataCache.CreateCollisionStagingCopy(); - PhysicsEngine stagingEngine = - Engine.CreateCollisionStagingCopy(stagingCache); - return new PreparedLandblockCollisionGeneration( + if (_preparedCollisionGenerations.Count + >= PreparedLandblockCollisionGeneration + .MaxConcurrentCollisionPreparations + && !_preparedCollisionGenerations.ContainsKey( + admission.LandblockId)) + { + throw new InvalidOperationException( + "Too many collision generations are being prepared concurrently."); + } + PhysicsEngine.CollisionStagingBuilder stagingBuilder = + Engine.CreateCollisionStagingBuilder(admission.LandblockId); + var prepared = new PreparedLandblockCollisionGeneration( this, admission, - stagingCache, - stagingEngine); + stagingBuilder, + _collisionOwnerJournal, + checked(++_nextCollisionPreparationSequence)); + _preparedCollisionGenerations[admission.LandblockId] = prepared; + _latestCollisionPreparationStartSequence = Math.Max( + _latestCollisionPreparationStartSequence, + prepared.OwnerMutationStartSequence); + return prepared; + } + + internal RuntimeCollisionPreparationStep + AdvanceCollisionGenerationPreparation( + RuntimeCollisionAdmission admission, + PreparedLandblockCollisionGeneration prepared) + { + ValidateAdmission(admission); + EnsureCollisionMutationThread(); + ValidatePreparedGeneration(admission, prepared); + RuntimeCollisionPreparationStep clone = prepared.AdvanceStagingClone(); + return clone; } /// @@ -1182,6 +1887,14 @@ public sealed class RuntimePhysicsState : IDisposable } prepared?.Dispose(); + if (prepared is not null + && _preparedCollisionGenerations.TryGetValue( + admission.LandblockId, + out PreparedLandblockCollisionGeneration? currentPrepared) + && ReferenceEquals(currentPrepared, prepared)) + { + _preparedCollisionGenerations.Remove(admission.LandblockId); + } if (_collisionAdmissions.TryGetValue( admission.LandblockId, out RuntimeCollisionAdmission? current) @@ -1191,6 +1904,7 @@ public sealed class RuntimePhysicsState : IDisposable _collisionGenerations[admission.LandblockId] = checked( admission.Generation + 1UL); } + TrimCollisionOwnerJournal(); } internal void StageCollisionAssets( @@ -1242,6 +1956,14 @@ public sealed class RuntimePhysicsState : IDisposable ValidateAdmission(admission); EnsureCollisionMutationThread(); ValidatePreparedGeneration(admission, prepared); + if (!prepared.AdvanceStagingClone().Completed) + { + return new RuntimeCollisionOwnerCaptureStep( + Completed: false, + Restarted: false, + HasOwner: false, + OwnerId: 0u); + } return prepared.AdvanceRetainedOwnerCapture(); } @@ -1278,7 +2000,76 @@ public sealed class RuntimePhysicsState : IDisposable throw new InvalidOperationException( "Collision generation cannot seal before its assets are prepared."); } - return prepared.AdvanceSeal(); + if (!prepared.StagingCloneComplete) + { + RuntimeCollisionPreparationStep preparation = + prepared.AdvanceStagingClone(); + return new RuntimeCollisionSealStep( + Completed: false, + Restarted: false, + WorkUnits: preparation.WorkUnits); + } + RuntimeCollisionJournalStep routed = + prepared.AdvanceRoutedOwnerMutation(); + if (routed.Worked) + { + return new RuntimeCollisionSealStep( + Completed: routed.Completed + && prepared.IsOwnerMutationReconciliationCurrent + && prepared.IsReadyForActivation, + Restarted: false, + WorkUnits: 1); + } + if (prepared.HasPendingRetirement) + { + PhysicsEngine.LandblockRetirementStep retirement = + prepared.AdvanceRetirement(); + return new RuntimeCollisionSealStep( + Completed: false, + Restarted: false, + WorkUnits: retirement.Worked ? 1 : 0); + } + if (prepared.HasPendingCommittedRebase) + { + PhysicsEngine.LandblockReplacementApplyStep rebase = + prepared.AdvanceCommittedRebase(); + return new RuntimeCollisionSealStep( + Completed: false, + Restarted: false, + WorkUnits: rebase.Worked ? 1 : 0); + } + RuntimeCollisionSealStep seal = prepared.AdvanceSeal(); + if (!seal.Completed) + return seal; + if (seal.WorkUnits != 0 + && !prepared.IsOwnerMutationReconciliationCurrent) + { + return new RuntimeCollisionSealStep( + Completed: false, + Restarted: false, + WorkUnits: seal.WorkUnits); + } + RuntimeCollisionJournalStep journal = + prepared.AdvanceOwnerMutationReconciliation(); + if (journal.Worked) + { + return new RuntimeCollisionSealStep( + Completed: journal.Completed, + Restarted: false, + WorkUnits: 1); + } + if (journal.Completed + && _collisionOwnerJournal.AdvanceCompaction()) + { + return new RuntimeCollisionSealStep( + Completed: true, + Restarted: false, + WorkUnits: 1); + } + return new RuntimeCollisionSealStep( + Completed: journal.Completed, + Restarted: false, + WorkUnits: seal.WorkUnits); } internal RuntimeCollisionGenerationCommit CommitCollisionGeneration( @@ -1299,15 +2090,17 @@ public sealed class RuntimePhysicsState : IDisposable "Collision generation has already completed."); } - if (!prepared.IsSealed) + if (!prepared.IsOwnerMutationReconciliationCurrent + || !prepared.IsReadyForActivation + || HasOlderPreparedGeneration(prepared) + || prepared.HasPendingCommittedRebase + || prepared.HasPendingRetirement) { - throw new InvalidOperationException( - "Collision generation cannot activate before sealing."); - } - if (Engine.ShadowObjects.MutationVersion - != prepared.SealedShadowMutationVersion) - { - prepared.ResetRetainedOwnerCapture(); + if (!prepared.IsSealed) + { + throw new InvalidOperationException( + "Collision generation cannot activate before sealing."); + } return new RuntimeCollisionGenerationCommit( new RuntimeCollisionAcknowledgement( admission.LandblockId, @@ -1316,11 +2109,29 @@ public sealed class RuntimePhysicsState : IDisposable Ready: false), Array.Empty()); } - - Engine.CommitLandblockReplacement(prepared.TakeSealedReplacement()); + PhysicsEngine.PreparedPhysicsEngineLandblock replacement = + prepared.TakeSealedReplacement(); + bool suppressOwnerJournal = _suppressCollisionOwnerJournal; + _suppressCollisionOwnerJournal = true; + try + { + Engine.CommitLandblockReplacement(replacement); + } + finally + { + _suppressCollisionOwnerJournal = suppressOwnerJournal; + } + foreach ((_, PreparedLandblockCollisionGeneration later) in + _preparedCollisionGenerations) + { + if (later.Sequence > prepared.Sequence) + later.EnqueueCommittedRebase(replacement); + } admission.Completed = true; _collisionAdmissions.Remove(admission.LandblockId); + _preparedCollisionGenerations.Remove(admission.LandblockId); prepared.MarkCommitted(); + TrimCollisionOwnerJournal(); var acknowledgement = new RuntimeCollisionAcknowledgement( admission.LandblockId, admission.Generation, @@ -1344,7 +2155,21 @@ public sealed class RuntimePhysicsState : IDisposable uint canonical = CanonicalLandblock(landblockId); bool resident = Engine.IsLandblockTerrainResident(canonical); InvalidateCollisionAdmission(canonical); - Engine.DemoteLandblockToTerrain(canonical); + bool suppressOwnerJournal = _suppressCollisionOwnerJournal; + _suppressCollisionOwnerJournal = true; + try + { + Engine.DemoteLandblockToTerrain(canonical); + } + finally + { + _suppressCollisionOwnerJournal = suppressOwnerJournal; + } + foreach ((_, PreparedLandblockCollisionGeneration prepared) in + _preparedCollisionGenerations) + { + prepared.RecordDemotion(canonical); + } return new RuntimeCollisionAcknowledgement( canonical, _collisionGenerations[canonical], @@ -1360,7 +2185,21 @@ public sealed class RuntimePhysicsState : IDisposable uint canonical = CanonicalLandblock(landblockId); bool resident = Engine.IsLandblockTerrainResident(canonical); InvalidateCollisionAdmission(canonical); - Engine.RemoveLandblock(canonical); + bool suppressOwnerJournal = _suppressCollisionOwnerJournal; + _suppressCollisionOwnerJournal = true; + try + { + Engine.RemoveLandblock(canonical); + } + finally + { + _suppressCollisionOwnerJournal = suppressOwnerJournal; + } + foreach ((_, PreparedLandblockCollisionGeneration prepared) in + _preparedCollisionGenerations) + { + prepared.RecordWithdrawal(canonical); + } return new RuntimeCollisionAcknowledgement( canonical, _collisionGenerations[canonical], @@ -1372,6 +2211,18 @@ public sealed class RuntimePhysicsState : IDisposable { if (_disposed) return; + _suppressCollisionOwnerJournal = true; + Engine.ShadowObjects.OwnerMutated -= OnCollisionOwnerMutated; + Engine.ShadowObjects.OwnerPrefixMembershipChanged -= + OnCollisionOwnerPrefixMembershipChanged; + foreach ((_, PreparedLandblockCollisionGeneration prepared) in + _preparedCollisionGenerations) + { + prepared.Dispose(); + } + _preparedCollisionGenerations.Clear(); + _collisionOwnerJournal.Clear(); + _collisionOwnerSubscribers.Clear(); Engine.Clear(); _spatialRemotes.Clear(); _spatialProjectiles.Clear(); @@ -1379,7 +2230,7 @@ public sealed class RuntimePhysicsState : IDisposable _collisionAdmissions.Clear(); _collisionGenerations.Clear(); CellCommitted = null; - CollisionGenerationCommitted = null; + _collisionGenerationCommittedObservers.Clear(); _disposed = true; } @@ -1510,13 +2361,13 @@ public sealed class RuntimePhysicsState : IDisposable private void PublishCollisionGenerationCommitted( RuntimeCollisionGenerationCommitted committed) { - Delegate[] observers = CollisionGenerationCommitted? - .GetInvocationList() ?? Array.Empty(); - foreach (Delegate observer in observers) + for (int index = 0; + index < _collisionGenerationCommittedObservers.Count; + index++) { try { - ((Action)observer)(committed); + _collisionGenerationCommittedObservers[index](committed); } catch (Exception error) { @@ -1536,6 +2387,126 @@ public sealed class RuntimePhysicsState : IDisposable : 1UL; _collisionGenerations[landblockId] = generation; _collisionAdmissions.Remove(landblockId); + if (_preparedCollisionGenerations.Remove( + landblockId, + out PreparedLandblockCollisionGeneration? prepared)) + { + prepared.Dispose(); + } + TrimCollisionOwnerJournal(); + } + + private void OnCollisionOwnerMutated(uint ownerId, ulong version) + { + _ = version; + if (_suppressCollisionOwnerJournal || _disposed) + return; + if (_preparedCollisionGenerations.Count == 0) + return; + long epochBefore = _collisionOwnerJournal.NextSequence; + int countBefore = _collisionOwnerJournal.Count; + CollisionOwnerMutationJournal.MutationRecord mutation = + _collisionOwnerJournal.Record( + ownerId, + version, + _latestCollisionPreparationStartSequence); + if (!_collisionOwnerSubscribers.TryGetValue( + ownerId, + out List? subscribers)) + { + return; + } + // Subscription mutation is update-thread confined. Iterate by index so + // no delegate-array or enumerator allocation enters the hot path. + for (int index = 0; index < subscribers.Count; index++) + { + subscribers[index].ObserveSubscribedOwnerMutation( + ownerId, + mutation.Sequence, + epochBefore, + countBefore); + } + } + + private void OnCollisionOwnerPrefixMembershipChanged( + uint ownerId, + uint landblockPrefix) + { + if (_suppressCollisionOwnerJournal || _disposed) + return; + uint canonical = (landblockPrefix & 0xFFFF0000u) | 0xFFFFu; + if (_preparedCollisionGenerations.TryGetValue( + canonical, + out PreparedLandblockCollisionGeneration? prepared)) + { + prepared.ObserveRoutedOwnerMembershipMutation(ownerId); + } + } + + internal void SubscribeCollisionOwner( + uint ownerId, + PreparedLandblockCollisionGeneration prepared) + { + if (!_collisionOwnerSubscribers.TryGetValue( + ownerId, + out List? subscribers)) + { + subscribers = new List(); + _collisionOwnerSubscribers[ownerId] = subscribers; + } + if (!subscribers.Contains(prepared)) + subscribers.Add(prepared); + } + + internal void UnsubscribeCollisionOwner( + uint ownerId, + PreparedLandblockCollisionGeneration prepared) + { + if (!_collisionOwnerSubscribers.TryGetValue( + ownerId, + out List? subscribers)) + { + return; + } + subscribers.Remove(prepared); + if (subscribers.Count == 0) + _collisionOwnerSubscribers.Remove(ownerId); + } + + private void TrimCollisionOwnerJournal() + { + if (_preparedCollisionGenerations.Count == 0) + { + _collisionOwnerJournal.Clear(); + _latestCollisionPreparationStartSequence = 0L; + return; + } + long minimumStart = long.MaxValue; + long latestStart = 0L; + foreach ((_, PreparedLandblockCollisionGeneration prepared) in + _preparedCollisionGenerations) + { + minimumStart = Math.Min( + minimumStart, + prepared.OwnerMutationStartSequence); + latestStart = Math.Max( + latestStart, + prepared.OwnerMutationStartSequence); + } + _latestCollisionPreparationStartSequence = latestStart; + _collisionOwnerJournal.RequestCompactionBefore(minimumStart); + } + + private bool HasOlderPreparedGeneration( + PreparedLandblockCollisionGeneration candidate) + { + foreach ((_, PreparedLandblockCollisionGeneration other) in + _preparedCollisionGenerations) + { + if (other.Sequence < candidate.Sequence) + return true; + } + return false; } private static uint CanonicalLandblock(uint value) => diff --git a/tests/AcDream.App.Tests/Streaming/LandblockPhysicsPublisherTests.cs b/tests/AcDream.App.Tests/Streaming/LandblockPhysicsPublisherTests.cs index b1d61c3b..a5f9106d 100644 --- a/tests/AcDream.App.Tests/Streaming/LandblockPhysicsPublisherTests.cs +++ b/tests/AcDream.App.Tests/Streaming/LandblockPhysicsPublisherTests.cs @@ -802,6 +802,10 @@ public sealed class LandblockPhysicsPublisherTests Assert.DoesNotContain("_physicsDataCache.RemoveBuildingsForLandblock", publisherSource, StringComparison.Ordinal); Assert.DoesNotContain("_physicsEngine.ShadowObjects.Reflood", publisherSource, StringComparison.Ordinal); Assert.Contains("CommitCollisionGeneration(", publisherSource, StringComparison.Ordinal); + Assert.DoesNotContain( + "RestartCollisionRetainedOwnerCapture(", + publisherSource, + StringComparison.Ordinal); } private static void Publish( diff --git a/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs b/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs index 473650b3..aea39519 100644 --- a/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs +++ b/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs @@ -482,7 +482,15 @@ public class PhysicsEngineTests [Fact] public void ResolveWithTransition_SelfShadowEntry_NotPushedWhenIdMatches() { - var engine = MakeFlatEngine(terrainZ: 50f); + var freshCache = new PhysicsDataCache(); + var engine = new PhysicsEngine { DataCache = freshCache }; + engine.AddLandblock( + 0xA9B4FFFFu, + new TerrainSurface(FlatHeightmap(50), LinearHeightTable()), + Array.Empty(), + Array.Empty(), + worldOffsetX: 0f, + worldOffsetY: 0f); // FindObjCollisions early-returns when DataCache is null. An empty // cache is enough for cylinder objects; only BSP objects look up // entries inside. @@ -490,9 +498,7 @@ public class PhysicsEngineTests // succeeds for the outdoor seed cell (0xA9B40039). In production the // streaming-center landblock is always resident before outdoor resolves run; // we replicate that invariant here by registering a flat dummy terrain. - var freshCache = new PhysicsDataCache(); freshCache.CellGraph.RegisterTerrain(0xA9B4FFFFu, new TerrainSurface(FlatHeightmap(50), LinearHeightTable()), Vector3.Zero); - engine.DataCache = freshCache; const uint movingEntityId = 0xDEADBEEFu; var bodyPos = new Vector3(96f, 96f, 50f); diff --git a/tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsStateTests.cs b/tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsStateTests.cs index e4e60922..0972e99a 100644 --- a/tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsStateTests.cs +++ b/tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsStateTests.cs @@ -4,6 +4,7 @@ using AcDream.Core.Physics; using AcDream.Runtime.Entities; using AcDream.Runtime.Physics; using AcDream.Core.Physics.Motion; +using AcDream.Core.World.Cells; using System.Numerics; namespace AcDream.Runtime.Tests.Physics; @@ -421,7 +422,7 @@ public sealed class RuntimePhysicsStateTests } [Fact] - public void WithdrawnOwnerStateChangeRejectsSealUntilRefreshed() + public void ArmedWithdrawnOwnerStateChangeWritesThroughSealedGeneration() { using var lifetime = new RuntimeEntityObjectLifetime(); RuntimePhysicsState physics = lifetime.Physics; @@ -464,15 +465,6 @@ public sealed class RuntimePhysicsStateTests uint owner = Assert.Single(SealPrepared(physics, admission, prepared)); physics.Engine.ShadowObjects.UpdatePhysicsState(owner, 0x14u); - RuntimeCollisionGenerationCommit rejected = - physics.CommitCollisionGeneration(admission, prepared); - Assert.False(rejected.Committed); - Assert.Equal(5f, physics.Engine.SampleTerrainZ(1f, 1f)); - - Assert.Equal(owner, Assert.Single(SealPrepared( - physics, - admission, - prepared))); Assert.True(physics.CommitCollisionGeneration( admission, prepared).Committed); @@ -582,6 +574,1853 @@ public sealed class RuntimePhysicsStateTests Assert.Equal(ownerCount, physics.Engine.ShadowObjects.TotalRegistered); } + [Fact] + public void UnrelatedOwnerMutationEveryStepCannotRestartTargetCaptureOrSeal() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimePhysicsState physics = lifetime.Physics; + const uint target = 0x0101FFFFu; + RuntimeCollisionAdmission initialAdmission = + physics.BeginCollisionAdmission(target); + using (PreparedLandblockCollisionGeneration initial = + physics.PrepareCollisionGeneration(initialAdmission)) + { + physics.StageCollisionAssets( + initialAdmission, + initial, + CollisionAssets(target, terrainHeight: 5f)); + Assert.True(CommitPrepared( + physics, + initialAdmission, + initial).Committed); + } + + physics.Engine.ShadowObjects.Register( + 42u, + 0x01000001u, + new Vector3(10f, 10f, 0f), + Quaternion.Identity, + 0.5f, + 0f, + 0f, + target, + seedCellId: 0x01010001u, + isStatic: false); + physics.Engine.ShadowObjects.Register( + 99u, + 0x01000001u, + new Vector3(10f, 10f, 0f), + Quaternion.Identity, + 0.5f, + 0f, + 0f, + 0x0303FFFFu, + seedCellId: 0x03030001u, + isStatic: false); + + RuntimeCollisionAdmission admission = + physics.BeginCollisionAdmission(target); + using PreparedLandblockCollisionGeneration prepared = + physics.PrepareCollisionGeneration(admission); + physics.StageCollisionAssets( + admission, + prepared, + CollisionAssets(target, terrainHeight: 15f)); + + int steps = 0; + RuntimeCollisionOwnerCaptureStep capture; + do + { + physics.Engine.ShadowObjects.UpdatePhysicsState( + 99u, + (uint)(steps + 1)); + capture = physics.AdvanceCollisionRetainedOwnerCapture( + admission, + prepared); + Assert.False(capture.Restarted); + // Admission now materializes its O(1) root snapshot one leaf per + // step before the target-owner scan. Unrelated owner churn must + // not restart either cursor. + Assert.True(++steps < 512); + } + while (!capture.Completed); + Assert.Equal(42u, Assert.Single(prepared.RetainedOwnerIds)); + physics.RefreshCollisionRetainedOwner(admission, prepared, 42u); + + RuntimeCollisionSealStep seal; + do + { + physics.Engine.ShadowObjects.UpdatePhysicsState( + 99u, + (uint)(steps + 1)); + seal = physics.AdvanceCollisionGenerationSeal( + admission, + prepared); + Assert.False(seal.Restarted); + Assert.InRange(seal.WorkUnits, 0, 1); + Assert.True(++steps < 256); + } + while (!seal.Completed); + + Assert.True(physics.CommitCollisionGeneration( + admission, + prepared).Committed); + Assert.Equal(15f, physics.Engine.SampleTerrainZ(1f, 1f)); + } + + [Fact] + public void TwoUnrelatedOwnersMovingEverySealStepCannotStarveActivation() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimePhysicsState physics = lifetime.Physics; + const uint target = 0x0101FFFFu; + foreach (uint ownerId in new[] { 99u, 100u }) + { + physics.Engine.ShadowObjects.Register( + ownerId, + 0x01000001u, + new Vector3(10f + ownerId - 99u, 10f, 0f), + Quaternion.Identity, + 0.5f, + 0f, + 0f, + 0x0303FFFFu, + seedCellId: 0x03030001u, + isStatic: false); + } + + RuntimeCollisionAdmission admission = + physics.BeginCollisionAdmission(target); + using PreparedLandblockCollisionGeneration prepared = + physics.PrepareCollisionGeneration(admission); + physics.StageCollisionAssets( + admission, + prepared, + CollisionAssets(target, terrainHeight: 15f)); + + int steps = 0; + RuntimeCollisionOwnerCaptureStep capture; + do + { + foreach (uint ownerId in new[] { 99u, 100u }) + { + physics.Engine.ShadowObjects.UpdatePhysicsState( + ownerId, + (uint)(steps + ownerId)); + } + capture = physics.AdvanceCollisionRetainedOwnerCapture( + admission, + prepared); + Assert.False(capture.Restarted); + Assert.True(++steps < 512); + } + while (!capture.Completed); + + while (true) + { + foreach (uint ownerId in new[] { 99u, 100u }) + { + physics.Engine.ShadowObjects.UpdatePosition( + ownerId, + new Vector3(10f + ownerId - 99u + steps, 10f, 0f), + Quaternion.Identity, + 0f, + 0f, + 0x0303FFFFu, + seedCellId: 0x03030001u); + physics.Engine.ShadowObjects.UpdatePhysicsState( + ownerId, + (uint)(steps + ownerId)); + } + + RuntimeCollisionSealStep seal = + physics.AdvanceCollisionGenerationSeal(admission, prepared); + Assert.False(seal.Restarted); + Assert.InRange(seal.WorkUnits, 0, 1); + Assert.True(++steps < 512); + if (seal.Completed) + break; + } + + Assert.True(physics.CommitCollisionGeneration( + admission, + prepared).Committed); + ShadowEntry[] entries = physics.Engine.ShadowObjects + .AllEntriesForDebug() + .OrderBy(entry => entry.EntityId) + .ToArray(); + Assert.Equal(new[] { 99u, 100u }, entries.Select(entry => entry.EntityId)); + foreach (ShadowEntry entry in entries) + { + Assert.Equal((uint)(steps - 1) + entry.EntityId, entry.State); + Assert.Equal( + 10f + entry.EntityId - 99u + steps - 1, + entry.Position.X); + } + } + + [Fact] + public void TwoRelevantOwnersMovingEverySealStepConvergeThroughWriteThrough() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimePhysicsState physics = lifetime.Physics; + const uint target = 0x0101FFFFu; + RuntimeCollisionAdmission initialAdmission = + physics.BeginCollisionAdmission(target); + using (PreparedLandblockCollisionGeneration initial = + physics.PrepareCollisionGeneration(initialAdmission)) + { + physics.StageCollisionAssets( + initialAdmission, + initial, + CollisionAssets(target, terrainHeight: 5f)); + Assert.True(CommitPrepared( + physics, + initialAdmission, + initial).Committed); + } + + foreach (uint ownerId in new[] { 42u, 43u }) + { + physics.Engine.ShadowObjects.Register( + ownerId, + 0x01000001u, + new Vector3(10f + ownerId - 42u, 10f, 0f), + Quaternion.Identity, + 0.5f, + 0f, + 0f, + target, + seedCellId: 0x01010001u, + isStatic: false); + } + + RuntimeCollisionAdmission admission = + physics.BeginCollisionAdmission(target); + using PreparedLandblockCollisionGeneration prepared = + physics.PrepareCollisionGeneration(admission); + physics.StageCollisionAssets( + admission, + prepared, + CollisionAssets(target, terrainHeight: 15f)); + while (!physics.AdvanceCollisionRetainedOwnerCapture( + admission, + prepared).Completed) + { + } + foreach (uint ownerId in prepared.RetainedOwnerIds) + physics.RefreshCollisionRetainedOwner(admission, prepared, ownerId); + + int step = 0; + RuntimeCollisionSealStep seal; + do + { + step++; + physics.Engine.ShadowObjects.UpdatePosition( + 42u, + new Vector3(10f + step * 0.01f, 10f, 0f), + Quaternion.Identity, + 0f, + 0f, + target, + seedCellId: 0x01010001u); + physics.Engine.ShadowObjects.UpdatePosition( + 43u, + new Vector3(11f + step * 0.01f, 10f, 0f), + Quaternion.Identity, + 0f, + 0f, + target, + seedCellId: 0x01010001u); + seal = physics.AdvanceCollisionGenerationSeal( + admission, + prepared); + Assert.False(seal.Restarted); + Assert.InRange(seal.WorkUnits, 0, 1); + Assert.True(step < 256); + } + while (!seal.Completed); + + Assert.True(physics.CommitCollisionGeneration( + admission, + prepared).Committed); + ShadowEntry[] entries = physics.Engine.ShadowObjects + .AllEntriesForDebug() + .OrderBy(entry => entry.EntityId) + .ToArray(); + Assert.Equal(2, entries.Length); + Assert.Equal(10f + step * 0.01f, entries[0].Position.X, 4); + Assert.Equal(11f + step * 0.01f, entries[1].Position.X, 4); + } + + [Fact] + public void FirstLoadWithNewStaticBucketActivatesWithZeroAllocation() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimePhysicsState physics = lifetime.Physics; + const uint target = 0x0101FFFFu; + RuntimeCollisionAdmission admission = + physics.BeginCollisionAdmission(target); + using PreparedLandblockCollisionGeneration prepared = + physics.PrepareCollisionGeneration(admission); + physics.StageCollisionAssets( + admission, + prepared, + CollisionAssets(target, terrainHeight: 12f)); + prepared.Engine.ShadowObjects.Register( + 500u, + 0x01000001u, + new Vector3(10f, 10f, 0f), + Quaternion.Identity, + 0.5f, + 0f, + 0f, + target, + seedCellId: 0x01010001u, + isStatic: true); + _ = SealPrepared(physics, admission, prepared); + + PhysicsDataCache cacheFacade = physics.DataCache; + CellGraph graphFacade = physics.DataCache.CellGraph; + ShadowObjectRegistry shadowFacade = physics.Engine.ShadowObjects; + int notifications = 0; + physics.CollisionGenerationCommitted += _ => notifications++; + _ = GC.GetAllocatedBytesForCurrentThread(); + long before = GC.GetAllocatedBytesForCurrentThread(); + RuntimeCollisionGenerationCommit commit = + physics.CommitCollisionGeneration(admission, prepared); + long allocated = + GC.GetAllocatedBytesForCurrentThread() - before; + + Assert.True(commit.Committed); + Assert.Equal(0L, allocated); + Assert.Equal(1, notifications); + Assert.Same(cacheFacade, physics.DataCache); + Assert.Same(graphFacade, physics.DataCache.CellGraph); + Assert.Same(shadowFacade, physics.Engine.ShadowObjects); + Assert.Equal(12f, physics.Engine.SampleTerrainZ(1f, 1f)); + Assert.Equal(500u, Assert.Single( + physics.Engine.ShadowObjects.AllEntriesForDebug()).EntityId); + } + + [Fact] + public void DenseResidentWorldAdmissionIsConstantAndMaterializesOneLeafPerStep() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimePhysicsState physics = lifetime.Physics; + const int residentLandblocks = 32; + for (int index = 0; index < residentLandblocks; index++) + { + uint prefix = (uint)(0x10 + index) << 24 | 0x010000u; + uint landblockId = prefix | 0xFFFFu; + RuntimeLandblockCollisionAssets assets = CollisionAssets( + landblockId, + terrainHeight: index); + physics.Engine.AddLandblock( + assets.LandblockId, + assets.Terrain, + assets.CellSurfaces, + assets.PortalPlanes, + assets.WorldOffsetX, + assets.WorldOffsetY); + AddSyntheticCell(physics.DataCache, prefix | 0x0100u); + physics.DataCache.RegisterBuildingForTest( + prefix | 1u, + SyntheticBuilding(Matrix4x4.Identity)); + physics.Engine.ShadowObjects.Register( + (uint)(10_000 + index), + 0x01000001u, + new Vector3(10f, 10f, 0f), + Quaternion.Identity, + 0.5f, + 0f, + 0f, + landblockId, + seedCellId: prefix | 1u, + isStatic: false); + } + + const uint target = 0x4001FFFFu; + RuntimeCollisionAdmission warmAdmission = + physics.BeginCollisionAdmission(0x3F01FFFFu); + PreparedLandblockCollisionGeneration warm = + physics.PrepareCollisionGeneration(warmAdmission); + physics.CancelCollisionGeneration(warmAdmission, warm); + + RuntimeCollisionAdmission admission = + physics.BeginCollisionAdmission(target); + _ = GC.GetAllocatedBytesForCurrentThread(); + long before = GC.GetAllocatedBytesForCurrentThread(); + using PreparedLandblockCollisionGeneration prepared = + physics.PrepareCollisionGeneration(admission); + long admissionAllocation = + GC.GetAllocatedBytesForCurrentThread() - before; + + Assert.InRange(admissionAllocation, 1L, 128L * 1024L); + Assert.Equal(0, prepared.Engine.LandblockCount); + + int advances = 0; + RuntimeCollisionPreparationStep step; + do + { + step = physics.AdvanceCollisionGenerationPreparation( + admission, + prepared); + Assert.InRange(step.WorkUnits, 0, 1); + Assert.True(++advances < 10_000); + } + while (!step.Completed); + + Assert.True(advances > residentLandblocks); + Assert.Equal(residentLandblocks, prepared.Engine.LandblockCount); + physics.CancelCollisionGeneration(admission, prepared); + } + + [Fact] + public void ChangedEnvCellsBuildingsAndStaticBucketActivateWithZeroAllocation() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimePhysicsState physics = lifetime.Physics; + const uint target = 0x0101FFFFu; + const uint oldCell = 0x01010100u; + const uint newCell = 0x01010101u; + const uint oldBuilding = 0x01010001u; + const uint newBuilding = 0x01010002u; + + RuntimeCollisionAdmission initialAdmission = + physics.BeginCollisionAdmission(target); + using (PreparedLandblockCollisionGeneration initial = + physics.PrepareCollisionGeneration(initialAdmission)) + { + physics.StageCollisionAssets( + initialAdmission, + initial, + CollisionAssets(target, terrainHeight: 5f)); + AddSyntheticCell(initial.DataCache, oldCell); + initial.DataCache.RegisterBuildingForTest( + oldBuilding, + SyntheticBuilding(Matrix4x4.Identity)); + initial.Engine.ShadowObjects.Register( + 600u, + 0x01000001u, + new Vector3(10f, 10f, 0f), + Quaternion.Identity, + 0.5f, + 0f, + 0f, + target, + seedCellId: 0x01010001u, + isStatic: true); + Assert.True(CommitPrepared( + physics, + initialAdmission, + initial).Committed); + } + + RuntimeCollisionAdmission admission = + physics.BeginCollisionAdmission(target); + using PreparedLandblockCollisionGeneration prepared = + physics.PrepareCollisionGeneration(admission); + prepared.DataCache.RemoveCellsForLandblock(target); + prepared.DataCache.RemoveBuildingsForLandblock(target); + prepared.DataCache.CellGraph.RemoveEnvCellsForLandblock(target); + prepared.Engine.ShadowObjects.DeregisterStaticOwnersForLandblock(target); + physics.StageCollisionAssets( + admission, + prepared, + CollisionAssets(target, terrainHeight: 15f)); + AddSyntheticCell(prepared.DataCache, newCell); + prepared.DataCache.RegisterBuildingForTest( + newBuilding, + SyntheticBuilding(Matrix4x4.CreateTranslation(1f, 0f, 0f))); + prepared.Engine.ShadowObjects.Register( + 601u, + 0x01000001u, + new Vector3(11f, 10f, 0f), + Quaternion.Identity, + 0.5f, + 0f, + 0f, + target, + seedCellId: 0x01010002u, + isStatic: true); + _ = SealPrepared(physics, admission, prepared); + + PhysicsDataCache cacheFacade = physics.DataCache; + CellGraph graphFacade = physics.DataCache.CellGraph; + ShadowObjectRegistry shadowFacade = physics.Engine.ShadowObjects; + _ = GC.GetAllocatedBytesForCurrentThread(); + long before = GC.GetAllocatedBytesForCurrentThread(); + RuntimeCollisionGenerationCommit commit = + physics.CommitCollisionGeneration(admission, prepared); + long allocated = + GC.GetAllocatedBytesForCurrentThread() - before; + + Assert.True(commit.Committed); + Assert.Equal(0L, allocated); + Assert.Same(cacheFacade, physics.DataCache); + Assert.Same(graphFacade, physics.DataCache.CellGraph); + Assert.Same(shadowFacade, physics.Engine.ShadowObjects); + Assert.Null(physics.DataCache.GetCellStruct(oldCell)); + Assert.NotNull(physics.DataCache.GetCellStruct(newCell)); + Assert.Null(physics.DataCache.GetBuilding(oldBuilding)); + Assert.NotNull(physics.DataCache.GetBuilding(newBuilding)); + Assert.Equal(601u, Assert.Single( + physics.Engine.ShadowObjects.AllEntriesForDebug()).EntityId); + } + + [Fact] + public void ConcurrentPreparedLandblocksRebaseBeforeSecondActivation() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimePhysicsState physics = lifetime.Physics; + const uint firstLandblock = 0x0101FFFFu; + const uint secondLandblock = 0x0202FFFFu; + RuntimeCollisionAdmission firstAdmission = + physics.BeginCollisionAdmission(firstLandblock); + RuntimeCollisionAdmission secondAdmission = + physics.BeginCollisionAdmission(secondLandblock); + using PreparedLandblockCollisionGeneration first = + physics.PrepareCollisionGeneration(firstAdmission); + using PreparedLandblockCollisionGeneration second = + physics.PrepareCollisionGeneration(secondAdmission); + physics.StageCollisionAssets( + firstAdmission, + first, + CollisionAssets(firstLandblock, terrainHeight: 5f)); + physics.StageCollisionAssets( + secondAdmission, + second, + CollisionAssets(secondLandblock, terrainHeight: 15f)); + first.Engine.ShadowObjects.Register( + 700u, + 0x01000001u, + new Vector3(10f, 10f, 0f), + Quaternion.Identity, + 0.5f, + 0f, + 0f, + firstLandblock, + seedCellId: 0x01010001u, + isStatic: true); + _ = SealPrepared(physics, firstAdmission, first); + _ = SealPrepared(physics, secondAdmission, second); + + _ = GC.GetAllocatedBytesForCurrentThread(); + long firstBefore = GC.GetAllocatedBytesForCurrentThread(); + Assert.True(physics.CommitCollisionGeneration( + firstAdmission, + first).Committed); + long firstAllocated = + GC.GetAllocatedBytesForCurrentThread() - firstBefore; + Assert.Equal(0L, firstAllocated); + Assert.True(second.IsSealed); + Assert.True(physics.Engine.IsLandblockTerrainResident(firstLandblock)); + Assert.False(physics.Engine.IsLandblockTerrainResident(secondLandblock)); + + _ = SealPrepared(physics, secondAdmission, second); + _ = GC.GetAllocatedBytesForCurrentThread(); + long secondBefore = GC.GetAllocatedBytesForCurrentThread(); + Assert.True(physics.CommitCollisionGeneration( + secondAdmission, + second).Committed); + long secondAllocated = + GC.GetAllocatedBytesForCurrentThread() - secondBefore; + Assert.Equal(0L, secondAllocated); + Assert.Equal(2, physics.Engine.LandblockCount); + Assert.True(physics.Engine.IsLandblockTerrainResident(firstLandblock)); + Assert.True(physics.Engine.IsLandblockTerrainResident(secondLandblock)); + Assert.Equal(700u, Assert.Single( + physics.Engine.ShadowObjects.AllEntriesForDebug()).EntityId); + } + + [Fact] + public void PostCommitOwnerMutationWinsOverQueuedPeerRebase() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimePhysicsState physics = lifetime.Physics; + const uint firstLandblock = 0x0101FFFFu; + const uint secondLandblock = 0x0202FFFFu; + RuntimeCollisionAdmission firstAdmission = + physics.BeginCollisionAdmission(firstLandblock); + using PreparedLandblockCollisionGeneration first = + physics.PrepareCollisionGeneration(firstAdmission); + physics.StageCollisionAssets( + firstAdmission, + first, + CollisionAssets(firstLandblock)); + first.Engine.ShadowObjects.Register( + 701u, + 0x01000001u, + new Vector3(10f, 10f, 0f), + Quaternion.Identity, + 0.5f, + 0f, + 0f, + firstLandblock, + seedCellId: 0x01010001u, + isStatic: false); + + RuntimeCollisionAdmission secondAdmission = + physics.BeginCollisionAdmission(secondLandblock); + using PreparedLandblockCollisionGeneration second = + physics.PrepareCollisionGeneration(secondAdmission); + physics.StageCollisionAssets( + secondAdmission, + second, + CollisionAssets(secondLandblock)); + _ = SealPrepared(physics, firstAdmission, first); + _ = SealPrepared(physics, secondAdmission, second); + + Assert.True(physics.CommitCollisionGeneration( + firstAdmission, + first).Committed); + physics.Engine.ShadowObjects.UpdatePosition( + 701u, + new Vector3(12f, 10f, 0f), + Quaternion.Identity, + 0f, + 0f, + firstLandblock, + seedCellId: 0x01010001u); + + _ = SealPrepared(physics, secondAdmission, second); + Assert.True(physics.CommitCollisionGeneration( + secondAdmission, + second).Committed); + Assert.Equal(12f, Assert.Single( + physics.Engine.ShadowObjects.AllEntriesForDebug()).Position.X); + } + + [Fact] + public void ConcurrentSeamStaticRefloodsAgainstLaterTopology() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimePhysicsState physics = lifetime.Physics; + const uint north = 0xA9B4FFFFu; + const uint south = 0xA9B3FFFFu; + foreach ((uint landblock, float offsetY) in new[] + { + (north, 0f), + (south, -192f), + }) + { + RuntimeCollisionAdmission seed = + physics.BeginCollisionAdmission(landblock); + using PreparedLandblockCollisionGeneration initial = + physics.PrepareCollisionGeneration(seed); + physics.StageCollisionAssets( + seed, + initial, + CollisionAssets(landblock) with + { + WorldOffsetY = offsetY, + }); + Assert.True(CommitPrepared(physics, seed, initial).Committed); + } + + RuntimeCollisionAdmission northAdmission = + physics.BeginCollisionAdmission(north); + using PreparedLandblockCollisionGeneration northPrepared = + physics.PrepareCollisionGeneration(northAdmission); + physics.StageCollisionAssets( + northAdmission, + northPrepared, + CollisionAssets(north)); + northPrepared.Engine.ShadowObjects.Register( + 702u, + 0x01000001u, + new Vector3(150f, 0.2f, 0f), + Quaternion.Identity, + 0.5f, + 0f, + 0f, + north, + seedCellId: 0xA9B40031u, + isStatic: true); + Assert.True(northPrepared.Engine.ShadowObjects.HasOwnerRowsInLandblock( + 702u, + south)); + + RuntimeCollisionAdmission southAdmission = + physics.BeginCollisionAdmission(south); + using PreparedLandblockCollisionGeneration southPrepared = + physics.PrepareCollisionGeneration(southAdmission); + physics.StageCollisionAssets( + southAdmission, + southPrepared, + CollisionAssets(south) with + { + WorldOffsetY = -192f, + }); + _ = SealPrepared(physics, northAdmission, northPrepared); + _ = SealPrepared(physics, southAdmission, southPrepared); + + Assert.True(physics.CommitCollisionGeneration( + northAdmission, + northPrepared).Committed); + _ = SealPrepared(physics, southAdmission, southPrepared); + Assert.True(physics.CommitCollisionGeneration( + southAdmission, + southPrepared).Committed); + Assert.True(physics.Engine.ShadowObjects.HasOwnerRowsInLandblock( + 702u, + south)); + } + + [Fact] + public void CancelledOlderPreparationNeverLeaksIntoLaterDraft() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimePhysicsState physics = lifetime.Physics; + const uint cancelledLandblock = 0x0101FFFFu; + const uint survivingLandblock = 0x0202FFFFu; + RuntimeCollisionAdmission cancelledAdmission = + physics.BeginCollisionAdmission(cancelledLandblock); + PreparedLandblockCollisionGeneration cancelled = + physics.PrepareCollisionGeneration(cancelledAdmission); + physics.StageCollisionAssets( + cancelledAdmission, + cancelled, + CollisionAssets(cancelledLandblock, terrainHeight: 11f)); + RuntimeCollisionAdmission survivingAdmission = + physics.BeginCollisionAdmission(survivingLandblock); + using PreparedLandblockCollisionGeneration surviving = + physics.PrepareCollisionGeneration(survivingAdmission); + physics.StageCollisionAssets( + survivingAdmission, + surviving, + CollisionAssets(survivingLandblock, terrainHeight: 22f)); + _ = SealPrepared(physics, cancelledAdmission, cancelled); + + physics.CancelCollisionGeneration(cancelledAdmission, cancelled); + Assert.True(CommitPrepared( + physics, + survivingAdmission, + surviving).Committed); + + Assert.False(physics.Engine.IsLandblockTerrainResident( + cancelledLandblock)); + Assert.True(physics.Engine.IsLandblockTerrainResident( + survivingLandblock)); + } + + [Fact] + public void OutgoingTargetStaticMutationCannotResurrectOmittedOwner() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimePhysicsState physics = lifetime.Physics; + const uint target = 0x0101FFFFu; + RuntimeCollisionAdmission initialAdmission = + physics.BeginCollisionAdmission(target); + using (PreparedLandblockCollisionGeneration initial = + physics.PrepareCollisionGeneration(initialAdmission)) + { + physics.StageCollisionAssets( + initialAdmission, + initial, + CollisionAssets(target)); + initial.Engine.ShadowObjects.Register( + 500u, + 0x01000001u, + new Vector3(10f, 10f, 0f), + Quaternion.Identity, + 0.5f, + 0f, + 0f, + target, + seedCellId: 0x01010001u, + isStatic: true); + Assert.True(CommitPrepared( + physics, + initialAdmission, + initial).Committed); + } + + RuntimeCollisionAdmission admission = + physics.BeginCollisionAdmission(target); + using PreparedLandblockCollisionGeneration prepared = + physics.PrepareCollisionGeneration(admission); + prepared.Engine.ShadowObjects.DeregisterStaticOwnersForLandblock(target); + physics.StageCollisionAssets( + admission, + prepared, + CollisionAssets(target, terrainHeight: 3f)); + physics.Engine.ShadowObjects.UpdatePhysicsState(500u, 0x44u); + + Assert.True(CommitPrepared(physics, admission, prepared).Committed); + Assert.Empty(physics.Engine.ShadowObjects.AllEntriesForDebug()); + } + + [Fact] + public void SamePrefixCurrentCellMoveAfterSealRebindsToNewRoot() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimePhysicsState physics = lifetime.Physics; + const uint target = 0x0101FFFFu; + const uint firstCell = 0x01010100u; + const uint secondCell = 0x01010101u; + RuntimeCollisionAdmission initialAdmission = + physics.BeginCollisionAdmission(target); + using (PreparedLandblockCollisionGeneration initial = + physics.PrepareCollisionGeneration(initialAdmission)) + { + physics.StageCollisionAssets( + initialAdmission, + initial, + CollisionAssets(target)); + AddSyntheticCell(initial.DataCache, firstCell); + AddSyntheticCell(initial.DataCache, secondCell); + Assert.True(CommitPrepared( + physics, + initialAdmission, + initial).Committed); + } + physics.Engine.UpdatePlayerCurrCell(firstCell); + + RuntimeCollisionAdmission admission = + physics.BeginCollisionAdmission(target); + using PreparedLandblockCollisionGeneration prepared = + physics.PrepareCollisionGeneration(admission); + prepared.DataCache.RemoveCellsForLandblock(target); + prepared.DataCache.CellGraph.RemoveEnvCellsForLandblock(target); + physics.StageCollisionAssets( + admission, + prepared, + CollisionAssets(target, terrainHeight: 5f)); + AddSyntheticCell(prepared.DataCache, firstCell); + AddSyntheticCell(prepared.DataCache, secondCell); + _ = SealPrepared(physics, admission, prepared); + + physics.Engine.UpdatePlayerCurrCell(secondCell); + ObjCell oldRootCell = physics.DataCache.CellGraph.CurrCell!; + Assert.True(physics.CommitCollisionGeneration( + admission, + prepared).Committed); + + ObjCell? rebound = physics.DataCache.CellGraph.GetVisible(secondCell); + Assert.NotNull(rebound); + Assert.NotSame(oldRootCell, rebound); + Assert.Same(rebound, physics.DataCache.CellGraph.CurrCell); + } + + [Fact] + public void ArmedOwnerLeavingTargetRemainsLiveInItsNewPrefix() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimePhysicsState physics = lifetime.Physics; + const uint target = 0x0101FFFFu; + const uint destination = 0x0202FFFFu; + foreach (uint landblock in new[] { target, destination }) + { + RuntimeCollisionAdmission seed = + physics.BeginCollisionAdmission(landblock); + using PreparedLandblockCollisionGeneration initial = + physics.PrepareCollisionGeneration(seed); + physics.StageCollisionAssets( + seed, + initial, + CollisionAssets(landblock, terrainHeight: 5f)); + Assert.True(CommitPrepared(physics, seed, initial).Committed); + } + + physics.Engine.ShadowObjects.Register( + 42u, + 0x01000001u, + new Vector3(10f, 10f, 0f), + Quaternion.Identity, + 0.5f, + 0f, + 0f, + target, + seedCellId: 0x01010001u, + isStatic: false); + RuntimeCollisionAdmission admission = + physics.BeginCollisionAdmission(target); + using PreparedLandblockCollisionGeneration prepared = + physics.PrepareCollisionGeneration(admission); + physics.StageCollisionAssets( + admission, + prepared, + CollisionAssets(target, terrainHeight: 9f)); + _ = SealPrepared(physics, admission, prepared); + + physics.Engine.ShadowObjects.UpdatePosition( + 42u, + new Vector3(11f, 10f, 0f), + Quaternion.Identity, + 0f, + 0f, + destination, + seedCellId: 0x02020001u); + + Assert.True(physics.CommitCollisionGeneration( + admission, + prepared).Committed); + Assert.False(physics.Engine.ShadowObjects.HasOwnerRowsInLandblock( + 42u, + target)); + Assert.True(physics.Engine.ShadowObjects.HasOwnerRowsInLandblock( + 42u, + destination)); + Assert.Equal(11f, Assert.Single( + physics.Engine.ShadowObjects.AllEntriesForDebug()).Position.X); + } + + [Fact] + public void ArmedOwnerIdReuseOutsideTargetKeepsNewIncarnationRows() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimePhysicsState physics = lifetime.Physics; + const uint target = 0x0101FFFFu; + const uint destination = 0x0202FFFFu; + foreach (uint landblock in new[] { target, destination }) + { + RuntimeCollisionAdmission seed = + physics.BeginCollisionAdmission(landblock); + using PreparedLandblockCollisionGeneration initial = + physics.PrepareCollisionGeneration(seed); + physics.StageCollisionAssets( + seed, + initial, + CollisionAssets(landblock)); + Assert.True(CommitPrepared(physics, seed, initial).Committed); + } + physics.Engine.ShadowObjects.Register( + 42u, + 0x01000001u, + new Vector3(10f, 10f, 0f), + Quaternion.Identity, + 0.5f, + 0f, + 0f, + target, + seedCellId: 0x01010001u, + isStatic: false); + + RuntimeCollisionAdmission admission = + physics.BeginCollisionAdmission(target); + using PreparedLandblockCollisionGeneration prepared = + physics.PrepareCollisionGeneration(admission); + physics.StageCollisionAssets( + admission, + prepared, + CollisionAssets(target, terrainHeight: 8f)); + _ = SealPrepared(physics, admission, prepared); + + physics.Engine.ShadowObjects.Deregister(42u); + physics.Engine.ShadowObjects.Register( + 42u, + 0x01000002u, + new Vector3(12f, 10f, 0f), + Quaternion.Identity, + 0.75f, + 0f, + 0f, + destination, + seedCellId: 0x02020001u, + isStatic: false); + + Assert.True(physics.CommitCollisionGeneration( + admission, + prepared).Committed); + ShadowEntry entry = Assert.Single( + physics.Engine.ShadowObjects.AllEntriesForDebug()); + Assert.Equal(0x01000002u, entry.GfxObjId); + Assert.Equal(12f, entry.Position.X); + Assert.True(physics.Engine.ShadowObjects.HasOwnerRowsInLandblock( + 42u, + destination)); + } + + [Fact] + public void UnrelatedStateMutationIsJournaledAfterAllRowsChange() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimePhysicsState physics = lifetime.Physics; + const uint target = 0x0101FFFFu; + const uint unrelated = 0x0202FFFFu; + foreach (uint landblock in new[] { target, unrelated }) + { + RuntimeCollisionAdmission seed = + physics.BeginCollisionAdmission(landblock); + using PreparedLandblockCollisionGeneration initial = + physics.PrepareCollisionGeneration(seed); + physics.StageCollisionAssets( + seed, + initial, + CollisionAssets(landblock)); + Assert.True(CommitPrepared(physics, seed, initial).Committed); + } + physics.Engine.ShadowObjects.Register( + 99u, + 0x01000001u, + new Vector3(10f, 10f, 0f), + Quaternion.Identity, + 0.5f, + 0f, + 0f, + unrelated, + state: 1u, + seedCellId: 0x02020001u, + isStatic: false); + + RuntimeCollisionAdmission admission = + physics.BeginCollisionAdmission(target); + using PreparedLandblockCollisionGeneration prepared = + physics.PrepareCollisionGeneration(admission); + physics.StageCollisionAssets( + admission, + prepared, + CollisionAssets(target, terrainHeight: 6f)); + physics.Engine.ShadowObjects.UpdatePhysicsState(99u, 0x1234u); + Assert.True(CommitPrepared(physics, admission, prepared).Committed); + + Assert.All( + physics.Engine.ShadowObjects.AllEntriesForDebug(), + entry => Assert.Equal(0x1234u, entry.State)); + } + + [Fact] + public void PrefixOwnerSlotsReuseTombstonesUnderGuidChurn() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimePhysicsState physics = lifetime.Physics; + const uint target = 0x0101FFFFu; + RuntimeCollisionAdmission admission = + physics.BeginCollisionAdmission(target); + using (PreparedLandblockCollisionGeneration prepared = + physics.PrepareCollisionGeneration(admission)) + { + physics.StageCollisionAssets( + admission, + prepared, + CollisionAssets(target)); + Assert.True(CommitPrepared( + physics, + admission, + prepared).Committed); + } + + for (uint ownerId = 1u; ownerId <= 2_000u; ownerId++) + { + physics.Engine.ShadowObjects.Register( + ownerId, + 0x01000001u, + new Vector3(10f, 10f, 0f), + Quaternion.Identity, + 0.5f, + 0f, + 0f, + target, + seedCellId: 0x01010001u, + isStatic: false); + physics.Engine.ShadowObjects.Deregister(ownerId); + } + + Assert.Equal(0, physics.Engine.ShadowObjects + .PrefixOwnerSlotCapacityForDiagnostics(target)); + Assert.Equal(0, physics.Engine.ShadowObjects + .OwnerVersionCountForDiagnostics); + } + + [Fact] + public void CommittedPreparationRevokesItsStagingCollisionRoot() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimePhysicsState physics = lifetime.Physics; + RuntimeCollisionAdmission admission = + physics.BeginCollisionAdmission(0x0101FFFFu); + PreparedLandblockCollisionGeneration prepared = + physics.PrepareCollisionGeneration(admission); + physics.StageCollisionAssets( + admission, + prepared, + CollisionAssets(0x0101FFFFu)); + Assert.True(CommitPrepared(physics, admission, prepared).Committed); + + Assert.Throws( + () => _ = prepared.Engine.LandblockCount); + prepared.Dispose(); + } + + [Fact] + public void UnrelatedDemotionAndWithdrawalCannotBeResurrectedByDraft() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimePhysicsState physics = lifetime.Physics; + const uint demoted = 0x0101FFFFu; + const uint withdrawn = 0x0202FFFFu; + const uint replacement = 0x0303FFFFu; + + RuntimeCollisionAdmission first = + physics.BeginCollisionAdmission(demoted); + using (PreparedLandblockCollisionGeneration preparedFirst = + physics.PrepareCollisionGeneration(first)) + { + physics.StageCollisionAssets( + first, + preparedFirst, + CollisionAssets(demoted)); + AddSyntheticCell(preparedFirst.DataCache, 0x01010100u); + Assert.True(CommitPrepared( + physics, + first, + preparedFirst).Committed); + } + RuntimeCollisionAdmission second = + physics.BeginCollisionAdmission(withdrawn); + using (PreparedLandblockCollisionGeneration preparedSecond = + physics.PrepareCollisionGeneration(second)) + { + physics.StageCollisionAssets( + second, + preparedSecond, + CollisionAssets(withdrawn)); + Assert.True(CommitPrepared( + physics, + second, + preparedSecond).Committed); + } + + RuntimeCollisionAdmission third = + physics.BeginCollisionAdmission(replacement); + using PreparedLandblockCollisionGeneration preparedThird = + physics.PrepareCollisionGeneration(third); + physics.StageCollisionAssets( + third, + preparedThird, + CollisionAssets(replacement)); + + Assert.True(physics.DemoteCollisionToTerrain(demoted).WasResident); + Assert.True(physics.WithdrawCollision(withdrawn).WasResident); + Assert.True(CommitPrepared( + physics, + third, + preparedThird).Committed); + + Assert.True(physics.Engine.IsLandblockTerrainResident(demoted)); + Assert.False(physics.DataCache.CellGraph.Contains(0x01010100u)); + Assert.False(physics.Engine.IsLandblockTerrainResident(withdrawn)); + Assert.True(physics.Engine.IsLandblockTerrainResident(replacement)); + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public void PendingOrActivePeerRebaseCannotResurrectRetiredLandblock( + bool withdraw, + bool beginRebase) + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimePhysicsState physics = lifetime.Physics; + const uint retired = 0x0101FFFFu; + const uint survivor = 0x0202FFFFu; + const uint retiredCell = 0x01010100u; + + RuntimeCollisionAdmission retiredAdmission = + physics.BeginCollisionAdmission(retired); + using PreparedLandblockCollisionGeneration retiredPrepared = + physics.PrepareCollisionGeneration(retiredAdmission); + physics.StageCollisionAssets( + retiredAdmission, + retiredPrepared, + CollisionAssets(retired, terrainHeight: 7f)); + AddSyntheticCell(retiredPrepared.DataCache, retiredCell); + + RuntimeCollisionAdmission survivorAdmission = + physics.BeginCollisionAdmission(survivor); + using PreparedLandblockCollisionGeneration survivorPrepared = + physics.PrepareCollisionGeneration(survivorAdmission); + physics.StageCollisionAssets( + survivorAdmission, + survivorPrepared, + CollisionAssets(survivor, terrainHeight: 9f)); + while (!physics.AdvanceCollisionGenerationPreparation( + survivorAdmission, + survivorPrepared).Completed) + { + } + + _ = SealPrepared(physics, retiredAdmission, retiredPrepared); + Assert.True(physics.CommitCollisionGeneration( + retiredAdmission, + retiredPrepared).Committed); + if (beginRebase) + { + for (int step = 0; step < 12; step++) + { + RuntimeCollisionSealStep started = + physics.AdvanceCollisionGenerationSeal( + survivorAdmission, + survivorPrepared); + Assert.False(started.Completed); + Assert.InRange(started.WorkUnits, 0, 1); + } + } + + if (withdraw) + Assert.True(physics.WithdrawCollision(retired).WasResident); + else + Assert.True(physics.DemoteCollisionToTerrain(retired).WasResident); + + Assert.True(CommitPrepared( + physics, + survivorAdmission, + survivorPrepared).Committed); + Assert.Equal(!withdraw, physics.Engine.IsLandblockTerrainResident(retired)); + Assert.Null(physics.DataCache.GetCellStruct(retiredCell)); + Assert.False(physics.DataCache.CellGraph.Contains(retiredCell)); + Assert.True(physics.Engine.IsLandblockTerrainResident(survivor)); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + public void OutgoingStaticDeletionCannotEraseSameIdAuthoredReplacement( + int deletionPhase) + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimePhysicsState physics = lifetime.Physics; + const uint target = 0x0101FFFFu; + const uint ownerId = 812u; + + RuntimeCollisionAdmission seedAdmission = + physics.BeginCollisionAdmission(target); + using (PreparedLandblockCollisionGeneration seed = + physics.PrepareCollisionGeneration(seedAdmission)) + { + physics.StageCollisionAssets( + seedAdmission, + seed, + CollisionAssets(target)); + seed.Engine.ShadowObjects.Register( + ownerId, + 0x01000001u, + new Vector3(10f, 10f, 0f), + Quaternion.Identity, + 0.5f, + 0f, + 0f, + target, + seedCellId: 0x01010001u, + isStatic: true); + Assert.True(CommitPrepared( + physics, + seedAdmission, + seed).Committed); + } + + RuntimeCollisionAdmission admission = + physics.BeginCollisionAdmission(target); + using PreparedLandblockCollisionGeneration prepared = + physics.PrepareCollisionGeneration(admission); + physics.StageCollisionAssets( + admission, + prepared, + CollisionAssets(target, terrainHeight: 4f)); + prepared.Engine.ShadowObjects.Register( + ownerId, + 0x01000002u, + new Vector3(13f, 10f, 0f), + Quaternion.Identity, + 0.75f, + 0f, + 0f, + target, + state: 0x55u, + seedCellId: 0x01010002u, + isStatic: true); + + if (deletionPhase == 1) + { + _ = physics.AdvanceCollisionGenerationPreparation( + admission, + prepared); + } + else if (deletionPhase == 2) + { + while (!physics.AdvanceCollisionGenerationPreparation( + admission, + prepared).Completed) + { + } + } + physics.Engine.ShadowObjects.Deregister(ownerId); + + Assert.True(CommitPrepared(physics, admission, prepared).Committed); + ShadowEntry authored = Assert.Single( + physics.Engine.ShadowObjects.AllEntriesForDebug(), + entry => entry.EntityId == ownerId); + Assert.Equal(0x01000002u, authored.GfxObjId); + Assert.Equal(13f, authored.Position.X); + Assert.Equal(0x55u, authored.State); + } + + [Fact] + public void OwnerJournalCoalescesUnrelatedChurnAcrossManyDrafts() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimePhysicsState physics = lifetime.Physics; + const uint ownerId = 900u; + physics.Engine.ShadowObjects.Register( + ownerId, + 0x01000001u, + new Vector3(10f, 10f, 0f), + Quaternion.Identity, + 0.5f, + 0f, + 0f, + 0x0101FFFFu, + seedCellId: 0x01010001u, + isStatic: false); + + physics.Engine.ShadowObjects.UpdatePhysicsState(ownerId, 1u); + _ = GC.GetAllocatedBytesForCurrentThread(); + long baselineBefore = GC.GetAllocatedBytesForCurrentThread(); + for (uint version = 2u; version <= 10_001u; version++) + physics.Engine.ShadowObjects.UpdatePhysicsState(ownerId, version); + long baselineAllocated = + GC.GetAllocatedBytesForCurrentThread() - baselineBefore; + + var admissions = new List(); + var preparations = new List(); + for (int index = 0; index < 32; index++) + { + uint landblock = ((uint)(0x20 + index) << 24) | 0x0001FFFFu; + RuntimeCollisionAdmission admission = + physics.BeginCollisionAdmission(landblock); + admissions.Add(admission); + preparations.Add(physics.PrepareCollisionGeneration(admission)); + } + + physics.Engine.ShadowObjects.UpdatePhysicsState(ownerId, 10_002u); + _ = GC.GetAllocatedBytesForCurrentThread(); + long before = GC.GetAllocatedBytesForCurrentThread(); + for (uint version = 10_003u; version <= 20_002u; version++) + physics.Engine.ShadowObjects.UpdatePhysicsState(ownerId, version); + long allocated = GC.GetAllocatedBytesForCurrentThread() - before; + + Assert.Equal(baselineAllocated, allocated); + Assert.Equal(1, physics.CollisionOwnerJournalEntryCountForDiagnostics); + for (int index = 0; index < admissions.Count; index++) + { + physics.CancelCollisionGeneration( + admissions[index], + preparations[index]); + } + Assert.Equal(0, physics.CollisionOwnerJournalEntryCountForDiagnostics); + } + + [Fact] + public void ManyUniqueOwnerMutationsReconcileOnePerSealAndCommitWithoutAllocation() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimePhysicsState physics = lifetime.Physics; + const uint target = 0x0101FFFFu; + const uint unrelated = 0x0202FFFFu; + const int ownerCount = 512; + + RuntimeCollisionAdmission unrelatedAdmission = + physics.BeginCollisionAdmission(unrelated); + using (PreparedLandblockCollisionGeneration unrelatedPrepared = + physics.PrepareCollisionGeneration(unrelatedAdmission)) + { + physics.StageCollisionAssets( + unrelatedAdmission, + unrelatedPrepared, + CollisionAssets(unrelated)); + Assert.True(CommitPrepared( + physics, + unrelatedAdmission, + unrelatedPrepared).Committed); + } + for (uint index = 0; index < ownerCount; index++) + { + physics.Engine.ShadowObjects.Register( + 30_000u + index, + 0x01000001u, + new Vector3(10f, 10f, 0f), + Quaternion.Identity, + 0.5f, + 0f, + 0f, + unrelated, + seedCellId: 0x02020001u, + isStatic: false); + } + + RuntimeCollisionAdmission admission = + physics.BeginCollisionAdmission(target); + using PreparedLandblockCollisionGeneration prepared = + physics.PrepareCollisionGeneration(admission); + physics.StageCollisionAssets( + admission, + prepared, + CollisionAssets(target)); + _ = SealPrepared(physics, admission, prepared); + + for (uint index = 0; index < ownerCount; index++) + { + physics.Engine.ShadowObjects.UpdatePhysicsState( + 30_000u + index, + index + 1u); + } + Assert.False(physics.CommitCollisionGeneration( + admission, + prepared).Committed); + + int worked = 0; + RuntimeCollisionSealStep seal; + do + { + seal = physics.AdvanceCollisionGenerationSeal( + admission, + prepared); + Assert.InRange(seal.WorkUnits, 0, 1); + worked += seal.WorkUnits; + } + while (!seal.Completed); + Assert.Equal(ownerCount, worked); + + _ = GC.GetAllocatedBytesForCurrentThread(); + long before = GC.GetAllocatedBytesForCurrentThread(); + RuntimeCollisionGenerationCommit commit = + physics.CommitCollisionGeneration(admission, prepared); + long allocated = GC.GetAllocatedBytesForCurrentThread() - before; + Assert.True(commit.Committed); + Assert.Equal(0L, allocated); + Assert.Equal(0, physics.CollisionOwnerJournalEntryCountForDiagnostics); + } + + [Fact] + public void CompactedJournalSupersessionPreservesTheMissedSuffix() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimePhysicsState physics = lifetime.Physics; + const uint oldestTarget = 0x0101FFFFu; + const uint survivingTarget = 0x0202FFFFu; + + RuntimeCollisionAdmission oldestAdmission = + physics.BeginCollisionAdmission(oldestTarget); + PreparedLandblockCollisionGeneration oldest = + physics.PrepareCollisionGeneration(oldestAdmission); + physics.Engine.ShadowObjects.Register( + 41_000u, + 0x01000001u, + new Vector3(10f, 10f, 0f), + Quaternion.Identity, + 0.5f, + 0f, + 0f, + 0x0303FFFFu, + seedCellId: 0x03030001u, + isStatic: false); + + RuntimeCollisionAdmission survivingAdmission = + physics.BeginCollisionAdmission(survivingTarget); + using PreparedLandblockCollisionGeneration surviving = + physics.PrepareCollisionGeneration(survivingAdmission); + physics.StageCollisionAssets( + survivingAdmission, + surviving, + CollisionAssets(survivingTarget)); + physics.CancelCollisionGeneration(oldestAdmission, oldest); + + _ = SealPrepared(physics, survivingAdmission, surviving); + RuntimeCollisionSealStep compacted = + physics.AdvanceCollisionGenerationSeal( + survivingAdmission, + surviving); + Assert.True(compacted.Completed); + Assert.Equal(1, compacted.WorkUnits); + Assert.Equal(0, physics.CollisionOwnerJournalEntryCountForDiagnostics); + + physics.Engine.ShadowObjects.Register( + 42_000u, + 0x01000001u, + new Vector3(12f, 10f, 0f), + Quaternion.Identity, + 0.5f, + 0f, + 0f, + survivingTarget, + seedCellId: 0x02020001u, + isStatic: false); + Assert.True(physics.CommitCollisionGeneration( + survivingAdmission, + surviving).Committed); + Assert.Contains( + physics.Engine.ShadowObjects.AllEntriesForDebug(), + entry => entry.EntityId == 42_000u); + } + + [Fact] + public void UnrelatedOwnerEnteringTargetAfterJournalScanWritesThroughExactly() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimePhysicsState physics = lifetime.Physics; + const uint target = 0x0101FFFFu; + const uint outside = 0x0202FFFFu; + const uint ownerId = 43_000u; + + physics.Engine.ShadowObjects.Register( + ownerId, + 0x01000001u, + new Vector3(10f, 10f, 0f), + Quaternion.Identity, + 0.5f, + 0f, + 0f, + outside, + seedCellId: 0x02020001u, + isStatic: false); + RuntimeCollisionAdmission admission = + physics.BeginCollisionAdmission(target); + using PreparedLandblockCollisionGeneration prepared = + physics.PrepareCollisionGeneration(admission); + physics.StageCollisionAssets( + admission, + prepared, + CollisionAssets(target)); + physics.Engine.ShadowObjects.UpdatePhysicsState(ownerId, 1u); + _ = SealPrepared(physics, admission, prepared); + + physics.Engine.ShadowObjects.UpdatePosition( + ownerId, + new Vector3(12f, 10f, 0f), + Quaternion.Identity, + 0f, + 0f, + target, + seedCellId: 0x01010001u); + Assert.True(physics.CommitCollisionGeneration( + admission, + prepared).Committed); + Assert.True(physics.Engine.ShadowObjects.HasOwnerRowsInLandblock( + ownerId, + target)); + } + + [Fact] + public void LateSamePrefixMutationOfScannedUnrelatedOwnerWritesThroughExactly() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimePhysicsState physics = lifetime.Physics; + const uint target = 0x0101FFFFu; + const uint outside = 0x0202FFFFu; + const uint ownerId = 43_500u; + + physics.Engine.ShadowObjects.Register( + ownerId, + 0x01000001u, + new Vector3(10f, 10f, 0f), + Quaternion.Identity, + 0.5f, + 0f, + 0f, + outside, + state: 1u, + seedCellId: 0x02020001u, + isStatic: false); + RuntimeCollisionAdmission admission = + physics.BeginCollisionAdmission(target); + using PreparedLandblockCollisionGeneration prepared = + physics.PrepareCollisionGeneration(admission); + physics.StageCollisionAssets( + admission, + prepared, + CollisionAssets(target)); + physics.Engine.ShadowObjects.UpdatePhysicsState(ownerId, 2u); + _ = SealPrepared(physics, admission, prepared); + + physics.Engine.ShadowObjects.UpdatePosition( + ownerId, + new Vector3(14f, 10f, 0f), + Quaternion.Identity, + 0f, + 0f, + outside, + seedCellId: 0x02020001u); + physics.Engine.ShadowObjects.UpdatePhysicsState(ownerId, 0x55u); + Assert.True(physics.CommitCollisionGeneration( + admission, + prepared).Committed); + ShadowEntry entry = Assert.Single( + physics.Engine.ShadowObjects.AllEntriesForDebug(), + item => item.EntityId == ownerId); + Assert.Equal(14f, entry.Position.X); + Assert.Equal(0x55u, entry.State); + } + + [Fact] + public void JournalTailCompactionRetiresItsFreeSlotMetadataIncrementally() + { + var journal = new CollisionOwnerMutationJournal(); + const int ownerCount = 4_096; + for (uint ownerId = 1u; ownerId <= ownerCount; ownerId++) + _ = journal.Record(ownerId, ownerId, journal.NextSequence); + journal.RequestCompactionBefore(journal.NextSequence); + + int steps = 0; + while (journal.AdvanceCompaction()) + Assert.True(++steps <= ownerCount * 2); + Assert.Equal(0, journal.Count); + Assert.Equal(0, journal.ActiveCount); + + CollisionOwnerMutationJournal.MutationRecord next = + journal.Record( + 99_999u, + 1u, + journal.NextSequence); + Assert.Equal(0, next.SlotIndex); + } + + [Fact] + public void RetirementAfterSealBlocksCommitUntilItsCursorCompletes() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimePhysicsState physics = lifetime.Physics; + const uint retired = 0x0101FFFFu; + const uint replacement = 0x0202FFFFu; + const uint retiredCell = 0x01010100u; + + RuntimeCollisionAdmission retiredAdmission = + physics.BeginCollisionAdmission(retired); + using (PreparedLandblockCollisionGeneration retiredPrepared = + physics.PrepareCollisionGeneration(retiredAdmission)) + { + physics.StageCollisionAssets( + retiredAdmission, + retiredPrepared, + CollisionAssets(retired)); + AddSyntheticCell(retiredPrepared.DataCache, retiredCell); + Assert.True(CommitPrepared( + physics, + retiredAdmission, + retiredPrepared).Committed); + } + + RuntimeCollisionAdmission replacementAdmission = + physics.BeginCollisionAdmission(replacement); + using PreparedLandblockCollisionGeneration replacementPrepared = + physics.PrepareCollisionGeneration(replacementAdmission); + physics.StageCollisionAssets( + replacementAdmission, + replacementPrepared, + CollisionAssets(replacement)); + _ = SealPrepared( + physics, + replacementAdmission, + replacementPrepared); + + Assert.True(physics.WithdrawCollision(retired).WasResident); + Assert.False(physics.CommitCollisionGeneration( + replacementAdmission, + replacementPrepared).Committed); + + _ = SealPrepared( + physics, + replacementAdmission, + replacementPrepared); + Assert.True(physics.CommitCollisionGeneration( + replacementAdmission, + replacementPrepared).Committed); + Assert.False(physics.Engine.IsLandblockTerrainResident(retired)); + Assert.Null(physics.DataCache.GetCellStruct(retiredCell)); + } + + [Fact] + public void MoreThanFixedRingWorthOfRetirementsRemainMeteredAndLossless() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimePhysicsState physics = lifetime.Physics; + const uint target = 0x0101FFFFu; + RuntimeCollisionAdmission admission = + physics.BeginCollisionAdmission(target); + using PreparedLandblockCollisionGeneration prepared = + physics.PrepareCollisionGeneration(admission); + physics.StageCollisionAssets( + admission, + prepared, + CollisionAssets(target)); + _ = SealPrepared(physics, admission, prepared); + + for (uint index = 0; index < 300u; index++) + { + uint ordinal = index + 0x1000u; + uint x = ordinal & 0xFFu; + uint y = (ordinal >> 8) & 0xFFu; + _ = physics.WithdrawCollision( + (x << 24) | (y << 16) | 0xFFFFu); + } + Assert.False(physics.CommitCollisionGeneration( + admission, + prepared).Committed); + + int steps = 0; + RuntimeCollisionSealStep seal; + do + { + seal = physics.AdvanceCollisionGenerationSeal( + admission, + prepared); + Assert.InRange(seal.WorkUnits, 0, 1); + Assert.True(++steps < 100_000); + } + while (!seal.Completed); + Assert.True(physics.CommitCollisionGeneration( + admission, + prepared).Committed); + } + + [Fact] + public void EmptyOwnerPrefixContainersAreReclaimedAcrossUniquePrefixes() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimePhysicsState physics = lifetime.Physics; + for (uint index = 1u; index <= 2_000u; index++) + { + uint x = index & 0xFFu; + uint y = (index >> 8) & 0xFFu; + uint prefix = (x << 24) | (y << 16); + uint ownerId = 20_000u + index; + physics.Engine.ShadowObjects.Register( + ownerId, + 0x01000001u, + new Vector3(10f, 10f, 0f), + Quaternion.Identity, + 0.5f, + 0f, + 0f, + prefix | 0xFFFFu, + seedCellId: prefix | 1u, + isStatic: false); + physics.Engine.ShadowObjects.Deregister(ownerId); + } + + Assert.Equal(0, physics.Engine.ShadowObjects + .PrefixOwnerContainerCountForDiagnostics); + Assert.Equal(0, physics.Engine.ShadowObjects + .OwnerVersionCountForDiagnostics); + } + + [Fact] + public void SealCursorRetainsCapturedSlotListsWhenPrefixContainerIsReclaimed() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimePhysicsState physics = lifetime.Physics; + const uint target = 0x0101FFFFu; + const uint ownerId = 41_000u; + + RuntimeCollisionAdmission seedAdmission = + physics.BeginCollisionAdmission(target); + using (PreparedLandblockCollisionGeneration seed = + physics.PrepareCollisionGeneration(seedAdmission)) + { + physics.StageCollisionAssets( + seedAdmission, + seed, + CollisionAssets(target)); + Assert.True(CommitPrepared( + physics, + seedAdmission, + seed).Committed); + } + physics.Engine.ShadowObjects.Register( + ownerId, + 0x01000001u, + new Vector3(10f, 10f, 0f), + Quaternion.Identity, + 0.5f, + 0f, + 0f, + target, + seedCellId: 0x01010001u, + isStatic: false); + + RuntimeCollisionAdmission admission = + physics.BeginCollisionAdmission(target); + using PreparedLandblockCollisionGeneration prepared = + physics.PrepareCollisionGeneration(admission); + physics.StageCollisionAssets( + admission, + prepared, + CollisionAssets(target, terrainHeight: 2f)); + while (!physics.AdvanceCollisionRetainedOwnerCapture( + admission, + prepared).Completed) + { + } + physics.RefreshCollisionRetainedOwner(admission, prepared, ownerId); + RuntimeCollisionSealStep started; + do + { + started = physics.AdvanceCollisionGenerationSeal( + admission, + prepared); + Assert.False(started.Completed); + } + while (started.WorkUnits == 0); + Assert.Equal(1, started.WorkUnits); + + physics.Engine.ShadowObjects.Deregister(ownerId); + Assert.Equal(0, physics.Engine.ShadowObjects + .PrefixOwnerContainerCountForDiagnostics); + + RuntimeCollisionSealStep seal; + do + { + seal = physics.AdvanceCollisionGenerationSeal( + admission, + prepared); + Assert.InRange(seal.WorkUnits, 0, 1); + } + while (!seal.Completed); + Assert.True(physics.CommitCollisionGeneration( + admission, + prepared).Committed); + Assert.Empty(physics.Engine.ShadowObjects.AllEntriesForDebug()); + } + + [Fact] + public void LiveCurrentCellIsNotRolledBackByTopologyActivation() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimePhysicsState physics = lifetime.Physics; + const uint first = 0x0101FFFFu; + const uint second = 0x0202FFFFu; + foreach (uint landblock in new[] { first, second }) + { + RuntimeCollisionAdmission seed = + physics.BeginCollisionAdmission(landblock); + using PreparedLandblockCollisionGeneration initial = + physics.PrepareCollisionGeneration(seed); + physics.StageCollisionAssets( + seed, + initial, + CollisionAssets(landblock)); + Assert.True(CommitPrepared(physics, seed, initial).Committed); + } + + RuntimeCollisionAdmission admission = + physics.BeginCollisionAdmission(first); + using PreparedLandblockCollisionGeneration prepared = + physics.PrepareCollisionGeneration(admission); + physics.StageCollisionAssets( + admission, + prepared, + CollisionAssets(first, terrainHeight: 7f)); + physics.Engine.UpdatePlayerCurrCell(0x02020001u); + Assert.Equal(0x02020001u, physics.DataCache.CellGraph.CurrCell?.Id); + + Assert.True(CommitPrepared(physics, admission, prepared).Committed); + Assert.Equal(0x02020001u, physics.DataCache.CellGraph.CurrCell?.Id); + } + [Fact] public void SpawnAndDeleteDuringStagingAreBothGenerationGated() { @@ -608,26 +2447,54 @@ public sealed class RuntimePhysicsStateTests 0x0101FFFFu, seedCellId: 0x01010001u, isStatic: false); - RuntimeCollisionGenerationCommit spawned = - physics.CommitCollisionGeneration(admission, prepared); - Assert.False(spawned.Committed); - - Assert.Equal(77u, Assert.Single(SealPrepared( - physics, - admission, - prepared))); physics.Engine.ShadowObjects.Deregister(77u); - RuntimeCollisionGenerationCommit deleted = - physics.CommitCollisionGeneration(admission, prepared); - Assert.False(deleted.Committed); - - Assert.Empty(SealPrepared(physics, admission, prepared)); Assert.True(physics.CommitCollisionGeneration( admission, prepared).Committed); Assert.Empty(physics.Engine.ShadowObjects.AllEntriesForDebug()); } + [Fact] + public void TwoPostSealOwnersWriteThroughBeforeActivation() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimePhysicsState physics = lifetime.Physics; + const uint target = 0x0101FFFFu; + RuntimeCollisionAdmission admission = + physics.BeginCollisionAdmission(target); + using PreparedLandblockCollisionGeneration prepared = + physics.PrepareCollisionGeneration(admission); + physics.StageCollisionAssets( + admission, + prepared, + CollisionAssets(target)); + Assert.Empty(SealPrepared(physics, admission, prepared)); + + foreach (uint ownerId in new[] { 71u, 72u }) + { + physics.Engine.ShadowObjects.Register( + ownerId, + 0x01000001u, + new Vector3(10f + ownerId - 71u, 10f, 0f), + Quaternion.Identity, + 0.5f, + 0f, + 0f, + target, + seedCellId: 0x01010001u, + isStatic: false); + } + Assert.True(physics.CommitCollisionGeneration( + admission, + prepared).Committed); + Assert.Equal( + new[] { 71u, 72u }, + physics.Engine.ShadowObjects.AllEntriesForDebug() + .Select(entry => entry.EntityId) + .Order() + .ToArray()); + } + [Fact] public void TerminalDisposalClearsLandblocksShadowsAndWorksets() { @@ -1073,6 +2940,39 @@ public sealed class RuntimePhysicsStateTests return [.. prepared.RetainedOwnerIds]; } + private static void AddSyntheticCell( + PhysicsDataCache cache, + uint cellId) + { + cache.RegisterCellStructForTest( + cellId, + new CellPhysics + { + Resolved = new Dictionary(), + }); + cache.CellGraph.Add(new EnvCell( + cellId, + Matrix4x4.Identity, + Matrix4x4.Identity, + Vector3.Zero, + Vector3.One, + Array.Empty(), + Array.Empty(), + seenOutside: false, + containmentBsp: null)); + } + + private static BuildingPhysics SyntheticBuilding(Matrix4x4 transform) + { + Matrix4x4.Invert(transform, out Matrix4x4 inverse); + return new BuildingPhysics + { + WorldTransform = transform, + InverseWorldTransform = inverse, + Portals = Array.Empty(), + }; + } + private static RuntimeLandblockCollisionAssets CollisionAssets( uint landblockId, float terrainHeight = 0f)