From d94145e6b88e38fc82f3b4b820e037f50b188bee Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 31 Jul 2026 15:53:05 +0200 Subject: [PATCH] fix(physics): seal collision generations before activation --- docs/architecture/acdream-architecture.md | 21 +- .../retail-divergence-register.md | 2 +- .../2026-07-31-atomic-collision-generation.md | 43 +- memory/project_collision_port.md | 14 +- .../Streaming/LandblockPhysicsPublisher.cs | 99 ++++- .../LandblockPresentationPipeline.cs | 12 +- src/AcDream.Core/Physics/PhysicsDataCache.cs | 334 +++++++++++--- src/AcDream.Core/Physics/PhysicsEngine.cs | 86 +++- .../Physics/ShadowObjectRegistry.cs | 419 +++++++++++++----- src/AcDream.Core/World/Cells/CellGraph.cs | 139 ++++-- .../Hosting/HeadlessSessionWorldProjection.cs | 186 +++++--- .../Physics/RuntimePhysicsState.cs | 283 ++++++++++-- .../LandblockPhysicsPublisherTests.cs | 37 ++ .../HeadlessSessionHostTests.cs | 86 ++++ .../Physics/RuntimePhysicsStateTests.cs | 205 +++++++-- 15 files changed, 1556 insertions(+), 410 deletions(-) diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md index d7b4cffb..d1d8dac8 100644 --- a/docs/architecture/acdream-architecture.md +++ b/docs/architecture/acdream-architecture.md @@ -488,14 +488,19 @@ What exists and is active: - Landblock collision activation is generation-owned by `RuntimePhysicsState`. Graphical and no-window hosts populate a private `PreparedLandblockCollisionGeneration` over bounded cursors; its cache, - `CellGraph`, engine landblock, buildings, static shadows, and dynamic-owner - refloods are never visible through the borrowed live engine. Runtime validates - the exact admission plus every affected dynamic shadow-owner version, seals - the complete replacement, and commits it synchronously on the one update - thread before emitting `CollisionGenerationCommitted`. A moving, spawned, or - deleted owner makes the generation not-ready and returns only that dirty owner - to the cursor-budgeted refresh phase. Thus readers see the complete old - generation or complete new generation, never a mixed cell/cache/shadow world. + `CellGraph`, engine landblock, buildings, static shadows, and retained-owner + refloods are never visible through the borrowed live engine. Retained owners + comprise every non-suspended dynamic or adjacent-root static touching the + 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. - `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 c531da62..1b19d620 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 retained dynamic-owner `recalc_cross_cells` is one Runtime-owned collision generation. App and Headless build only an off-side `PreparedLandblockCollisionGeneration`; the complete previous generation remains queryable until the one update-thread commit. Every affected dynamic owner carries an exact mutation version. Movement, spawn, or deletion during staging rejects activation and returns only dirty owners to a bounded refresh cursor; a stale admission/recenter/cancellation can dispose only its private generation. The commit installs the precomputed cell rows 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`, `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` | — | — | `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 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-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 abdeb784..0e92fba1 100644 --- a/docs/research/2026-07-31-atomic-collision-generation.md +++ b/docs/research/2026-07-31-atomic-collision-generation.md @@ -25,19 +25,25 @@ The asynchronous unit is now one Runtime-owned collision generation: 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 - collision assets, static owners, and retained dynamic-owner cell sets only - into that private generation. -4. Each dynamic owner refresh captures its exact `ShadowObjectRegistry` - mutation version. Movement, state/payload change, spawn, suspension, or - deletion changes that version. -5. `CommitCollisionGeneration` validates the admission, exact affected-owner - set, and all captured versions. If any owner is dirty, it returns the sorted - dirty IDs without touching the active world; App advances those IDs through - its existing work meter and retries. -6. Once fresh, Runtime precomputes the replacement arrays and synchronously - replaces the landblock's cache/graph/engine/building/static and dynamic - shadow state on the same update thread. Only after the complete replacement - does it emit `CollisionGenerationCommitted` and a ready acknowledgement. + 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 + 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 + 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. The stable borrowed `PhysicsEngine` and `PhysicsDataCache` object identities do not change. Presentation and no-window hosts use the same Runtime transaction. @@ -47,6 +53,9 @@ shadow state. ## Failure and lifetime rules - A newer admission invalidates an older prepared generation. +- Cancellation names one admission and its private staging generation. It can + never withdraw or demote the active landblock, and cancelling a stale receipt + cannot invalidate a newer admission. - Demotion, withdrawal, reset, and disposal invalidate the admission before changing the active generation. - Disposing a stale/cancelled prepared generation clears only its private @@ -64,7 +73,15 @@ The focused Runtime/App tests pin: - 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; +- 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; +- 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; - 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 1da3577c..f75de062 100644 --- a/memory/project_collision_port.md +++ b/memory/project_collision_port.md @@ -12,11 +12,15 @@ as "delete everything and start over." A partial retail transition port exists: - `PhysicsDataCache` loads GfxObj, Setup, and CellStruct physics data. - Landblock streaming never edits the live collision world incrementally. `PreparedLandblockCollisionGeneration` owns an off-side cache, CellGraph, - engine snapshot, buildings, statics, and versioned dynamic-owner refloods; - `RuntimePhysicsState.CommitCollisionGeneration` validates freshness and - activates the complete generation on the single update thread. A dirty live - owner returns to the App cursor for refresh. The old generation remains - queryable until that commit. See + 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 + commit. See `docs/research/2026-07-31-atomic-collision-generation.md`. - `ShadowObjectRegistry` gives the resolver a broadphase over nearby objects. - `TerrainSurface` uses triangle-aware terrain contact. diff --git a/src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs b/src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs index ffccba70..8f34842d 100644 --- a/src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs +++ b/src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs @@ -21,6 +21,7 @@ public sealed class LandblockPhysicsPublication : IDisposable uint currentCellId, BuildingInfo[] buildings, uint[] priorStaticOwnerIds, + RuntimePhysicsState physics, RuntimeCollisionAdmission collisionAdmission, PreparedLandblockCollisionGeneration preparedGeneration) { @@ -30,6 +31,7 @@ public sealed class LandblockPhysicsPublication : IDisposable CurrentCellId = currentCellId; Buildings = buildings; PriorStaticOwnerIds = priorStaticOwnerIds; + Physics = physics; CollisionAdmission = collisionAdmission; PreparedGeneration = preparedGeneration; } @@ -39,6 +41,7 @@ public sealed class LandblockPhysicsPublication : IDisposable internal uint CurrentCellId { get; } internal BuildingInfo[] Buildings { get; } internal uint[] PriorStaticOwnerIds { get; } + internal RuntimePhysicsState Physics { get; } internal RuntimeCollisionAdmission CollisionAdmission { get; } internal PreparedLandblockCollisionGeneration PreparedGeneration { get; } internal PhysicsDataCache StagingCache => PreparedGeneration.DataCache; @@ -63,16 +66,19 @@ public sealed class LandblockPhysicsPublication : IDisposable internal int CylinderOwnerCount { get; set; } internal int NoCollisionCount { get; set; } internal int SceneryTried { get; set; } - internal uint[]? RefloodOwnerIds { get; set; } + internal IReadOnlyList? RefloodOwnerIds { get; set; } internal int RefloodCursor { get; set; } internal bool RefloodCommitted { get; set; } + internal bool SealCommitted { get; set; } internal bool BeginCommitted { get; set; } internal bool CompletionCommitted { get; set; } public void Dispose() { if (!CompletionCommitted) - PreparedGeneration.Dispose(); + Physics.CancelCollisionGeneration( + CollisionAdmission, + PreparedGeneration); } public uint LandblockId => Build.Landblock.LandblockId; @@ -217,23 +223,34 @@ public sealed class LandblockPhysicsPublisher ?? Array.Empty(); RuntimeCollisionAdmission collisionAdmission = _physics.BeginCollisionAdmission(build.Landblock.LandblockId); - var publication = new LandblockPhysicsPublication( - _receiptOwner, - build, - origin, - _physicsDataCache.CellGraph.CurrCell?.Id ?? 0u, - buildings, - _physicsEngine.ShadowObjects.CaptureStaticOwnersForLandblock( - build.Landblock.LandblockId), - collisionAdmission, - _physics.PrepareCollisionGeneration(collisionAdmission)); - publication.SetupObjectIds = build.Collisions is { } collisions - ? [.. collisions.SetupIds] - : datBundle.Setups.Keys.Order().ToArray(); - publication.PreparedGeneration.SetAssetClosure( - publication.GfxObjectIds, - publication.SetupObjectIds); - return publication; + PreparedLandblockCollisionGeneration? prepared = null; + try + { + prepared = _physics.PrepareCollisionGeneration(collisionAdmission); + var publication = new LandblockPhysicsPublication( + _receiptOwner, + build, + origin, + _physicsDataCache.CellGraph.CurrCell?.Id ?? 0u, + buildings, + _physicsEngine.ShadowObjects.CaptureStaticOwnersForLandblock( + build.Landblock.LandblockId), + _physics, + collisionAdmission, + prepared); + publication.SetupObjectIds = build.Collisions is { } collisions + ? [.. collisions.SetupIds] + : datBundle.Setups.Keys.Order().ToArray(); + publication.PreparedGeneration.SetAssetClosure( + publication.GfxObjectIds, + publication.SetupObjectIds); + return publication; + } + catch + { + _physics.CancelCollisionGeneration(collisionAdmission, prepared); + throw; + } } /// @@ -481,15 +498,20 @@ public sealed class LandblockPhysicsPublisher } else if (publication.RefloodOwnerIds is null) { - publication.RefloodOwnerIds = - _physics.CaptureCollisionDynamicOwners( + RuntimeCollisionOwnerCaptureStep capture = + _physics.AdvanceCollisionRetainedOwnerCapture( publication.CollisionAdmission, publication.PreparedGeneration); + if (capture.Completed) + { + publication.RefloodOwnerIds = + publication.PreparedGeneration.RetainedOwnerIds; + } } else if (publication.RefloodCursor - < publication.RefloodOwnerIds.Length) + < publication.RefloodOwnerIds.Count) { - _physics.RefreshCollisionDynamicOwner( + _physics.RefreshCollisionRetainedOwner( publication.CollisionAdmission, publication.PreparedGeneration, publication.RefloodOwnerIds[publication.RefloodCursor]); @@ -507,6 +529,25 @@ public sealed class LandblockPhysicsPublisher LogMissingSceneryBounds(landblock, publication.StagingCache); publication.RefloodCommitted = true; } + else if (!publication.SealCommitted) + { + RuntimeCollisionSealStep seal = + _physics.AdvanceCollisionGenerationSeal( + publication.CollisionAdmission, + publication.PreparedGeneration); + if (seal.WorkUnits > 1) + { + throw new InvalidOperationException( + "Collision seal exceeded its one-unit publication budget."); + } + publication.SealCommitted = seal.Completed; + if (seal.Restarted) + { + publication.RefloodOwnerIds = null; + publication.RefloodCursor = 0; + publication.RefloodCommitted = false; + } + } else { RuntimeCollisionGenerationCommit commit = @@ -515,9 +556,13 @@ public sealed class LandblockPhysicsPublisher publication.PreparedGeneration); if (!commit.Committed) { - publication.RefloodOwnerIds = commit.DirtyDynamicOwnerIds; + _physics.RestartCollisionRetainedOwnerCapture( + publication.CollisionAdmission, + publication.PreparedGeneration); + publication.RefloodOwnerIds = null; publication.RefloodCursor = 0; publication.RefloodCommitted = false; + publication.SealCommitted = false; _completePublishTicks += Stopwatch.GetTimestamp() - started; return false; } @@ -1011,5 +1056,11 @@ public sealed class LandblockPhysicsPublisher "The physics publication receipt belongs to another publisher.", nameof(publication)); } + if (!publication.CompletionCommitted) + { + ObjectDisposedException.ThrowIf( + publication.PreparedGeneration.IsDisposed, + publication); + } } } diff --git a/src/AcDream.App/Streaming/LandblockPresentationPipeline.cs b/src/AcDream.App/Streaming/LandblockPresentationPipeline.cs index 6f18c36c..5f7c9fff 100644 --- a/src/AcDream.App/Streaming/LandblockPresentationPipeline.cs +++ b/src/AcDream.App/Streaming/LandblockPresentationPipeline.cs @@ -731,13 +731,15 @@ public sealed class LandblockPresentationPipeline < transaction.Build.Landblock.Entities.Count ? 1 : transaction.PhysicsPublication - .RefloodOwnerIds is not null - && transaction.PhysicsPublication + .RefloodOwnerIds is null + || transaction.PhysicsPublication .RefloodCursor < transaction.PhysicsPublication - .RefloodOwnerIds.Length - ? 1 - : 0; + .RefloodOwnerIds.Count + || !transaction.PhysicsPublication + .SealCommitted + ? 1 + : 0; if (!TryRun( new StreamingWorkCost( EntityOperations: entityOperations), diff --git a/src/AcDream.Core/Physics/PhysicsDataCache.cs b/src/AcDream.Core/Physics/PhysicsDataCache.cs index ba9b3e36..8be11764 100644 --- a/src/AcDream.Core/Physics/PhysicsDataCache.cs +++ b/src/AcDream.Core/Physics/PhysicsDataCache.cs @@ -121,31 +121,24 @@ public sealed class PhysicsDataCache return copy; } - internal PreparedPhysicsDataCacheLandblock PrepareLandblockReplacement( + internal LandblockReplacementBuilder CreateLandblockReplacementBuilder( + PhysicsDataCache staging, uint landblockId, - ReadOnlySpan gfxObjectIds, - ReadOnlySpan setupIds) - { - uint prefix = landblockId & 0xFFFF0000u; - return new PreparedPhysicsDataCacheLandblock( - prefix, - CaptureRequested(_gfxObj, gfxObjectIds), - CaptureRequested(_visualBounds, gfxObjectIds), - CaptureRequested(_flatGfxObj, gfxObjectIds), - CaptureRequested(_setup, setupIds), - CaptureRequested(_flatSetup, setupIds), - CapturePrefix(_cellStruct, prefix), - CapturePrefix(_flatCellStruct, prefix), - CapturePrefix(_flatEnvCell, prefix), - CapturePrefix(_buildings, prefix), - CellGraph.PrepareLandblockReplacement(prefix)); - } + uint[] gfxObjectIds, + uint[] setupIds) => new( + this, + staging, + landblockId, + gfxObjectIds, + setupIds); internal void CommitLandblockReplacement( PreparedPhysicsDataCacheLandblock replacement) { - RemoveCellsForLandblock(replacement.LandblockPrefix); - RemoveBuildingsForLandblock(replacement.LandblockPrefix); + 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); @@ -166,34 +159,14 @@ public sealed class PhysicsDataCache destination.TryAdd(id, value); } - private static KeyValuePair[] CaptureRequested( - ConcurrentDictionary source, - ReadOnlySpan ids) - { - var result = new List>(ids.Length); - for (int index = 0; index < ids.Length; index++) - { - uint id = ids[index]; - if (source.TryGetValue(id, out T? value)) - result.Add(new KeyValuePair(id, value)); - } - return result.ToArray(); - } - - private static KeyValuePair[] CapturePrefix( - ConcurrentDictionary source, - uint prefix) => source - .Where(pair => (pair.Key & 0xFFFF0000u) == prefix) - .OrderBy(static pair => pair.Key) - .ToArray(); - private static void CommitEntries( ConcurrentDictionary destination, - KeyValuePair[] entries, + IReadOnlyList> entries, bool replace) { - foreach ((uint id, T value) in entries) + for (int index = 0; index < entries.Count; index++) { + (uint id, T value) = entries[index]; if (replace) destination[id] = value; else @@ -201,6 +174,14 @@ public sealed class PhysicsDataCache } } + 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 @@ -1043,19 +1024,268 @@ public sealed class PhysicsDataCache /// Test helper, mirrors . public void RegisterBuildingForTest(uint landcellId, BuildingPhysics b) => _buildings[landcellId] = b; + + internal sealed class LandblockReplacementBuilder : IDisposable + { + private readonly PhysicsDataCache _active; + private readonly PhysicsDataCache _staging; + private readonly uint _prefix; + private readonly uint[] _gfxIds; + private readonly uint[] _setupIds; + private readonly List> _gfx = new(); + private readonly List> _bounds = new(); + private readonly List> _flatGfx = new(); + private readonly List> _setups = new(); + private readonly List> _flatSetups = new(); + private readonly List> _cells = new(); + private readonly List> _flatCells = new(); + private readonly List> _flatEnvCells = new(); + private readonly List> _buildings = new(); + private readonly HashSet _cellIds = new(); + private readonly HashSet _flatCellIds = new(); + private readonly HashSet _flatEnvCellIds = new(); + private readonly HashSet _buildingIds = new(); + private readonly List _removeCells = new(); + private readonly List _removeFlatCells = new(); + private readonly List _removeFlatEnvCells = new(); + private readonly List _removeBuildings = new(); + private readonly UcgCellGraph.LandblockReplacementBuilder _cellGraph; + private IEnumerator>? _cellEnumerator; + private IEnumerator>? _flatCellEnumerator; + private IEnumerator>? _flatEnvEnumerator; + private IEnumerator>? _buildingEnumerator; + private int _phase; + private int _cursor; + + internal LandblockReplacementBuilder( + PhysicsDataCache active, + PhysicsDataCache staging, + uint landblockId, + uint[] gfxIds, + uint[] setupIds) + { + _active = active; + _staging = staging; + _prefix = landblockId & 0xFFFF0000u; + _gfxIds = gfxIds; + _setupIds = setupIds; + _cellGraph = active.CellGraph.CreateLandblockReplacementBuilder( + staging.CellGraph, + _prefix); + } + + internal int WorkUnits { get; private set; } + internal PreparedPhysicsDataCacheLandblock? Prepared { get; private set; } + + internal bool Advance() + { + switch (_phase) + { + case 0: + if (_cursor < _gfxIds.Length) + { + uint id = _gfxIds[_cursor++]; + Capture(_staging._gfxObj, id, _gfx); + Capture(_staging._visualBounds, id, _bounds); + Capture(_staging._flatGfxObj, id, _flatGfx); + WorkUnits++; + return false; + } + _cursor = 0; + _phase++; + return false; + case 1: + if (_cursor < _setupIds.Length) + { + uint id = _setupIds[_cursor++]; + Capture(_staging._setup, id, _setups); + Capture(_staging._flatSetup, id, _flatSetups); + WorkUnits++; + return false; + } + _phase++; + return false; + case 2: + _cellEnumerator ??= _staging._cellStruct.GetEnumerator(); + if (CapturePrefixOne(_cellEnumerator, _prefix, _cells, _cellIds)) + { + WorkUnits++; + return false; + } + _cellEnumerator.Dispose(); + _cellEnumerator = null; + _phase++; + return false; + case 3: + _cellEnumerator ??= _active._cellStruct.GetEnumerator(); + if (CaptureRemovalOne(_cellEnumerator, _prefix, _cellIds, _removeCells)) + { + WorkUnits++; + return false; + } + _cellEnumerator.Dispose(); + _cellEnumerator = null; + _phase++; + return false; + case 4: + _flatCellEnumerator ??= _staging._flatCellStruct.GetEnumerator(); + if (CapturePrefixOne(_flatCellEnumerator, _prefix, _flatCells, _flatCellIds)) + { + WorkUnits++; + return false; + } + _flatCellEnumerator.Dispose(); + _flatCellEnumerator = null; + _phase++; + return false; + case 5: + _flatCellEnumerator ??= _active._flatCellStruct.GetEnumerator(); + if (CaptureRemovalOne(_flatCellEnumerator, _prefix, _flatCellIds, _removeFlatCells)) + { + WorkUnits++; + return false; + } + _flatCellEnumerator.Dispose(); + _flatCellEnumerator = null; + _phase++; + return false; + case 6: + _flatEnvEnumerator ??= _staging._flatEnvCell.GetEnumerator(); + if (CapturePrefixOne(_flatEnvEnumerator, _prefix, _flatEnvCells, _flatEnvCellIds)) + { + WorkUnits++; + return false; + } + _flatEnvEnumerator.Dispose(); + _flatEnvEnumerator = null; + _phase++; + return false; + case 7: + _flatEnvEnumerator ??= _active._flatEnvCell.GetEnumerator(); + if (CaptureRemovalOne(_flatEnvEnumerator, _prefix, _flatEnvCellIds, _removeFlatEnvCells)) + { + WorkUnits++; + return false; + } + _flatEnvEnumerator.Dispose(); + _flatEnvEnumerator = null; + _phase++; + return false; + case 8: + _buildingEnumerator ??= _staging._buildings.GetEnumerator(); + if (CapturePrefixOne(_buildingEnumerator, _prefix, _buildings, _buildingIds)) + { + WorkUnits++; + return false; + } + _buildingEnumerator.Dispose(); + _buildingEnumerator = null; + _phase++; + return false; + case 9: + _buildingEnumerator ??= _active._buildings.GetEnumerator(); + if (CaptureRemovalOne(_buildingEnumerator, _prefix, _buildingIds, _removeBuildings)) + { + WorkUnits++; + return false; + } + _buildingEnumerator.Dispose(); + _buildingEnumerator = null; + _phase++; + return false; + case 10: + WorkUnits++; + if (!_cellGraph.Advance()) + return false; + Prepared = new PreparedPhysicsDataCacheLandblock( + _prefix, + _gfx, + _bounds, + _flatGfx, + _setups, + _flatSetups, + _removeCells, + _cells, + _removeFlatCells, + _flatCells, + _removeFlatEnvCells, + _flatEnvCells, + _removeBuildings, + _buildings, + _cellGraph.Prepared!); + _phase++; + return true; + default: + return true; + } + } + + private static void Capture( + ConcurrentDictionary source, + uint id, + List> destination) + { + if (source.TryGetValue(id, out T? value)) + destination.Add(new KeyValuePair(id, value)); + } + + private static bool CapturePrefixOne( + IEnumerator> enumerator, + uint prefix, + List> destination, + HashSet ids) + { + if (!enumerator.MoveNext()) + return false; + KeyValuePair pair = enumerator.Current; + if ((pair.Key & 0xFFFF0000u) == prefix) + { + destination.Add(pair); + ids.Add(pair.Key); + } + return true; + } + + private static bool CaptureRemovalOne( + IEnumerator> enumerator, + uint prefix, + HashSet retained, + List destination) + { + if (!enumerator.MoveNext()) + return false; + uint id = enumerator.Current.Key; + if ((id & 0xFFFF0000u) == prefix && !retained.Contains(id)) + destination.Add(id); + return true; + } + + public void Dispose() + { + _cellEnumerator?.Dispose(); + _flatCellEnumerator?.Dispose(); + _flatEnvEnumerator?.Dispose(); + _buildingEnumerator?.Dispose(); + _cellGraph.Dispose(); + } + } } internal sealed record PreparedPhysicsDataCacheLandblock( uint LandblockPrefix, - KeyValuePair[] GfxObjects, - KeyValuePair[] VisualBounds, - KeyValuePair[] FlatGfxObjects, - KeyValuePair[] Setups, - KeyValuePair[] FlatSetups, - KeyValuePair[] Cells, - KeyValuePair[] FlatCells, - KeyValuePair[] FlatEnvCells, - KeyValuePair[] Buildings, + IReadOnlyList> GfxObjects, + IReadOnlyList> VisualBounds, + IReadOnlyList> FlatGfxObjects, + IReadOnlyList> Setups, + IReadOnlyList> FlatSetups, + IReadOnlyList CellIdsToRemove, + IReadOnlyList> Cells, + IReadOnlyList FlatCellIdsToRemove, + IReadOnlyList> FlatCells, + IReadOnlyList FlatEnvCellIdsToRemove, + IReadOnlyList> FlatEnvCells, + IReadOnlyList BuildingIdsToRemove, + IReadOnlyList> Buildings, PreparedCellGraphLandblock CellGraph); /// diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs index 017b44c1..cd7e6b07 100644 --- a/src/AcDream.Core/Physics/PhysicsEngine.cs +++ b/src/AcDream.Core/Physics/PhysicsEngine.cs @@ -182,12 +182,12 @@ public sealed class PhysicsEngine return staging; } - internal PreparedPhysicsEngineLandblock PrepareLandblockReplacement( + internal LandblockReplacementBuilder CreateLandblockReplacementBuilder( PhysicsEngine staging, uint landblockId, - ReadOnlySpan gfxObjectIds, - ReadOnlySpan setupIds, - IReadOnlyDictionary expectedDynamicVersions) + uint[] gfxObjectIds, + uint[] setupIds, + IReadOnlyDictionary expectedRetainedVersions) { ArgumentNullException.ThrowIfNull(staging); uint canonical = (landblockId & 0xFFFF0000u) | 0xFFFFu; @@ -201,31 +201,25 @@ public sealed class PhysicsEngine PhysicsDataCache stagingCache = staging.DataCache ?? throw new InvalidOperationException( "Staging collision engine has no data cache."); - return new PreparedPhysicsEngineLandblock( + return new LandblockReplacementBuilder( canonical, landblock, - stagingCache.PrepareLandblockReplacement( + (DataCache ?? throw new InvalidOperationException( + "Active collision engine has no data cache.")) + .CreateLandblockReplacementBuilder( + stagingCache, canonical, gfxObjectIds, setupIds), - ShadowObjects.PrepareLandblockReplacement( + ShadowObjects.CreateLandblockReplacementBuilder( staging.ShadowObjects, canonical, - expectedDynamicVersions)); + expectedRetainedVersions)); } - internal bool ValidateLandblockReplacement( - PreparedPhysicsEngineLandblock replacement) => - ShadowObjects.ValidateLandblockReplacement(replacement.Shadows); - internal void CommitLandblockReplacement( PreparedPhysicsEngineLandblock replacement) { - if (!ValidateLandblockReplacement(replacement)) - { - throw new InvalidOperationException( - "Collision generation changed after it was sealed."); - } (DataCache ?? throw new InvalidOperationException( "Active collision engine has no data cache.")) .CommitLandblockReplacement(replacement.DataCache); @@ -253,6 +247,64 @@ public sealed class PhysicsEngine internal ShadowObjectRegistry.PreparedLandblockShadowReplacement Shadows { get; } } + internal sealed class LandblockReplacementBuilder : IDisposable + { + private readonly uint _landblockId; + private readonly LandblockPhysics _landblock; + private readonly PhysicsDataCache.LandblockReplacementBuilder _data; + private readonly ShadowObjectRegistry.LandblockReplacementBuilder _shadows; + private int _phase; + + internal LandblockReplacementBuilder( + uint landblockId, + LandblockPhysics landblock, + PhysicsDataCache.LandblockReplacementBuilder data, + ShadowObjectRegistry.LandblockReplacementBuilder shadows) + { + _landblockId = landblockId; + _landblock = landblock; + _data = data; + _shadows = shadows; + } + + internal int WorkUnits => _data.WorkUnits + _shadows.WorkUnits; + internal bool IsStable => _shadows.IsStable; + internal PreparedPhysicsEngineLandblock? Prepared { get; private set; } + + internal bool Advance() + { + if (_phase == 0) + { + if (!_data.Advance()) + return false; + _phase++; + return false; + } + if (_phase == 1) + { + if (!_shadows.Advance()) + return false; + if (IsStable && _data.Prepared is not null + && _shadows.Prepared is not null) + { + Prepared = new PreparedPhysicsEngineLandblock( + _landblockId, + _landblock, + _data.Prepared, + _shadows.Prepared); + } + _phase++; + } + return true; + } + + public void Dispose() + { + _data.Dispose(); + _shadows.Dispose(); + } + } + /// /// Register a landblock with its terrain surface, indoor cells, portal /// planes, and world-space origin offset. diff --git a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs index 6e7afc6d..2c2d6fdc 100644 --- a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs +++ b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs @@ -52,6 +52,7 @@ public sealed class ShadowObjectRegistry /// private readonly Dictionary _entityReg = new(); private readonly Dictionary _ownerVersions = new(); + private ulong _mutationVersion; internal sealed record RegistrationRecord( uint SeedCellId, @@ -76,8 +77,88 @@ public sealed class ShadowObjectRegistry private void BumpOwnerVersion(uint entityId) { _ownerVersions[entityId] = checked(GetOwnerVersion(entityId) + 1UL); + _mutationVersion = checked(_mutationVersion + 1UL); } + internal ulong MutationVersion => _mutationVersion; + + internal RetainedRefloodOwnerScan CreateRetainedRefloodOwnerScan( + uint landblockId) => new(this, landblockId & 0xFFFF0000u); + + internal sealed class RetainedRefloodOwnerScan : IDisposable + { + private readonly ShadowObjectRegistry _owner; + private readonly uint _prefix; + private readonly ulong _sourceMutationVersion; + private Dictionary.Enumerator _enumerator; + private bool _completed; + + internal RetainedRefloodOwnerScan( + ShadowObjectRegistry owner, + uint prefix) + { + _owner = owner; + _prefix = prefix; + _sourceMutationVersion = owner.MutationVersion; + _enumerator = owner._entityReg.GetEnumerator(); + } + + internal RetainedRefloodOwnerScanStep Advance() + { + if (_completed) + { + return new RetainedRefloodOwnerScanStep( + Completed: true, + Stable: _owner.MutationVersion == _sourceMutationVersion, + HasOwner: false, + OwnerId: 0u, + SourceMutationVersion: _sourceMutationVersion); + } + if (_owner.MutationVersion != _sourceMutationVersion) + { + _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); + } + + (uint ownerId, RegistrationRecord registration) = + _enumerator.Current; + bool retained = !_owner._suspendedEntities.Contains(ownerId) + && (!registration.IsStatic + || (registration.SeedCellId & 0xFFFF0000u) != _prefix) + && _owner.OwnerTouchesLandblock(ownerId, _prefix); + return new RetainedRefloodOwnerScanStep( + Completed: false, + Stable: true, + HasOwner: retained, + OwnerId: retained ? ownerId : 0u, + SourceMutationVersion: _sourceMutationVersion); + } + + public void Dispose() => _enumerator.Dispose(); + } + + internal readonly record struct RetainedRefloodOwnerScanStep( + bool Completed, + bool Stable, + bool HasOwner, + uint OwnerId, + ulong SourceMutationVersion); + /// /// The flood's data source (cells, buildings, terrain origins). Wired by /// when its own DataCache is set. @@ -624,8 +705,14 @@ public sealed class ShadowObjectRegistry { // Suspended dynamic objects have no cell rows, but their retained // registration must still receive authoritative state changes. - if (_entityReg.TryGetValue(entityId, out var retainedRegistration)) - _entityReg[entityId] = retainedRegistration with { State = newState }; + bool retained = _entityReg.TryGetValue( + entityId, + out RegistrationRecord? retainedRegistration); + if (retained) + { + _entityReg[entityId] = retainedRegistration! with { State = newState }; + BumpOwnerVersion(entityId); + } if (!_entityToCells.TryGetValue(entityId, out var cellIds)) return; // not registered — no-op @@ -640,9 +727,6 @@ public sealed class ShadowObjectRegistry } } - if (_entityReg.TryGetValue(entityId, out var reg)) - _entityReg[entityId] = reg with { State = newState }; - BumpOwnerVersion(entityId); } /// Remove an entity from all cells it was registered in. @@ -657,7 +741,7 @@ public sealed class ShadowObjectRegistry foreach (var cellId in cellIds) { if (_cells.TryGetValue(cellId, out var list)) - list.RemoveAll(e => e.EntityId == entityId); + RemoveOwnerRows(list, entityId); } _entityToCells.Remove(entityId); } @@ -669,6 +753,17 @@ public sealed class ShadowObjectRegistry BumpOwnerVersion(entityId); } + private static void RemoveOwnerRows( + List entries, + uint entityId) + { + for (int index = entries.Count - 1; index >= 0; index--) + { + if (entries[index].EntityId == entityId) + entries.RemoveAt(index); + } + } + /// /// Logically tear down every static object owned by a landblock, including /// shadow rows flooded into adjacent landblocks. Dynamic/server-live owners @@ -824,6 +919,11 @@ public sealed class ShadowObjectRegistry /// Suspended logical registrations awaiting spatial re-entry. public int SuspendedRegistrationCount => _suspendedEntities.Count; + public bool HasOwnerRowsInLandblock(uint ownerId, uint landblockId) => + _entityToCells.TryGetValue(ownerId, out List? cells) + && cells.Exists(cell => + (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 @@ -860,16 +960,7 @@ public sealed class ShadowObjectRegistry } foreach ((uint ownerId, ulong version) in source._ownerVersions) _ownerVersions[ownerId] = version; - } - - internal uint[] CaptureDynamicRefloodOwnersForLandblock( - uint landblockId) - { - uint[] owners = CaptureRefloodOwnersForLandblock(landblockId); - return owners.Where(ownerId => - _entityReg.TryGetValue(ownerId, out RegistrationRecord? record) - && !record.IsStatic) - .ToArray(); + _mutationVersion = source._mutationVersion; } /// @@ -877,7 +968,7 @@ public sealed class ShadowObjectRegistry /// it against the staging generation's complete cell graph. The returned /// source version is the commit-time freshness token. /// - internal bool RefreshDynamicOwnerFrom( + internal bool RefreshRetainedOwnerFrom( ShadowObjectRegistry source, uint entityId, uint landblockId, @@ -889,8 +980,10 @@ public sealed class ShadowObjectRegistry if (!source._entityReg.TryGetValue( entityId, out RegistrationRecord? registration) - || registration.IsStatic || source._suspendedEntities.Contains(entityId) + || (registration.IsStatic + && (registration.SeedCellId & 0xFFFF0000u) + == (landblockId & 0xFFFF0000u)) || !source.OwnerTouchesLandblock(entityId, landblockId)) { return false; @@ -912,7 +1005,7 @@ public sealed class ShadowObjectRegistry 0f, landblockId, registration.SeedCellId, - isStatic: false); + isStatic: registration.IsStatic); } else { @@ -931,92 +1024,42 @@ public sealed class ShadowObjectRegistry registration.State, registration.Flags, registration.SeedCellId, - isStatic: false); + isStatic: registration.IsStatic); + } + + if (source._withdrawnPrefixesByOwner.TryGetValue( + entityId, + out HashSet? sourceWithdrawn)) + { + var retainedWithdrawn = new HashSet(sourceWithdrawn); + uint prefix = landblockId & 0xFFFF0000u; + if (_entityToCells.TryGetValue(entityId, out List? cells) + && cells.Exists(cell => (cell & 0xFFFF0000u) == prefix)) + { + retainedWithdrawn.Remove(prefix); + } + if (retainedWithdrawn.Count != 0) + _withdrawnPrefixesByOwner[entityId] = retainedWithdrawn; } return true; } - internal uint[] FindDirtyDynamicOwners( - uint landblockId, - IReadOnlyDictionary expectedVersions) - { - var dirty = new HashSet( - CaptureDynamicRefloodOwnersForLandblock(landblockId)); - dirty.UnionWith(expectedVersions.Keys); - dirty.RemoveWhere(ownerId => - expectedVersions.TryGetValue(ownerId, out ulong expected) - && OwnerTouchesLandblock(ownerId, landblockId) - && GetOwnerVersion(ownerId) == expected); - uint[] result = dirty.ToArray(); - Array.Sort(result); - return result; - } - - internal PreparedLandblockShadowReplacement PrepareLandblockReplacement( + internal LandblockReplacementBuilder CreateLandblockReplacementBuilder( ShadowObjectRegistry staging, uint landblockId, - IReadOnlyDictionary expectedDynamicVersions) - { - ArgumentNullException.ThrowIfNull(staging); - uint[] dirty = FindDirtyDynamicOwners( + IReadOnlyDictionary expectedRetainedVersions) => new( + this, + staging, landblockId, - expectedDynamicVersions); - if (dirty.Length != 0) - { - throw new InvalidOperationException( - "Dynamic shadow owners changed before collision generation sealing."); - } - - var owners = new HashSet(CaptureStaticOwnersForLandblock(landblockId)); - owners.UnionWith(staging.CaptureStaticOwnersForLandblock(landblockId)); - owners.UnionWith(expectedDynamicVersions.Keys); - uint[] ownerIds = owners.ToArray(); - Array.Sort(ownerIds); - var states = new List(ownerIds.Length); - foreach (uint ownerId in ownerIds) - { - if (staging.TryCaptureOwnerState(ownerId, out PreparedShadowOwnerState? state) - && state is not null) - states.Add(state); - } - return new PreparedLandblockShadowReplacement( - landblockId & 0xFFFF0000u, - ownerIds, - states.ToArray(), - expectedDynamicVersions.ToDictionary( - static pair => pair.Key, - static pair => pair.Value)); - } - - internal bool ValidateLandblockReplacement( - PreparedLandblockShadowReplacement replacement) - { - foreach ((uint ownerId, ulong version) in replacement.DynamicVersions) - { - if (GetOwnerVersion(ownerId) != version - || !OwnerTouchesLandblock(ownerId, replacement.LandblockPrefix)) - { - return false; - } - } - return CaptureDynamicRefloodOwnersForLandblock( - replacement.LandblockPrefix) - .SequenceEqual(replacement.DynamicVersions.Keys.Order()); - } + expectedRetainedVersions); internal void CommitLandblockReplacement( PreparedLandblockShadowReplacement replacement) { - if (!ValidateLandblockReplacement(replacement)) - { - throw new InvalidOperationException( - "Dynamic shadow owners changed before collision generation commit."); - } - - foreach (uint ownerId in replacement.OwnerIds) - Deregister(ownerId); - foreach (PreparedShadowOwnerState state in replacement.OwnerStates) - InstallOwnerState(state); + 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) @@ -1071,10 +1114,10 @@ public sealed class ShadowObjectRegistry entityId, registration, shapes, - cells?.ToArray() ?? Array.Empty(), - rows.ToArray(), + cells is null ? null : new List(cells), + rows, _suspendedEntities.Contains(entityId), - withdrawn?.ToArray() ?? Array.Empty()); + withdrawn is null ? null : new HashSet(withdrawn)); return true; } @@ -1085,49 +1128,194 @@ public sealed class ShadowObjectRegistry _entityShapes[state.EntityId] = state.Shapes; if (state.Suspended) _suspendedEntities.Add(state.EntityId); - if (state.WithdrawnPrefixes.Length != 0) + if (state.WithdrawnPrefixes is not null) { - _withdrawnPrefixesByOwner[state.EntityId] = - new HashSet(state.WithdrawnPrefixes); + _withdrawnPrefixesByOwner[state.EntityId] = state.WithdrawnPrefixes; } - if (state.CellIds.Length != 0) - _entityToCells[state.EntityId] = new List(state.CellIds); - foreach (PreparedShadowCellRows row in state.Rows) + if (state.CellIds is not null) + _entityToCells[state.EntityId] = state.CellIds; + for (int rowIndex = 0; rowIndex < state.Rows.Count; rowIndex++) { - foreach (ShadowEntry entry in row.Entries) - AddEntryToCell(entry, row.CellId); + PreparedShadowCellRows row = state.Rows[rowIndex]; + for (int entryIndex = 0; entryIndex < row.Entries.Length; entryIndex++) + AddEntryToCell(row.Entries[entryIndex], row.CellId); } BumpOwnerVersion(state.EntityId); } + internal sealed class LandblockReplacementBuilder : IDisposable + { + private readonly ShadowObjectRegistry _active; + private readonly ShadowObjectRegistry _staging; + private readonly uint _prefix; + private readonly ulong _sourceMutationVersion; + 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 int _phase; + + internal LandblockReplacementBuilder( + ShadowObjectRegistry active, + ShadowObjectRegistry staging, + uint landblockId, + IReadOnlyDictionary expected) + { + _active = active; + _staging = staging; + _prefix = landblockId & 0xFFFF0000u; + _sourceMutationVersion = active.MutationVersion; + _expectedEnumerator = expected.GetEnumerator(); + } + + 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()) + { + (uint ownerId, ulong version) = _expectedEnumerator.Current; + if (_active.GetOwnerVersion(ownerId) != version + || !_active.IsRetainedRefloodOwner(ownerId, _prefix)) + { + return true; + } + AddOwner(ownerId); + WorkUnits++; + return false; + } + _expectedEnumerator.Dispose(); + _expectedEnumerator = null; + _registrationEnumerator = _active._entityReg.GetEnumerator(); + _phase++; + return false; + case 1: + if (_registrationEnumerator.MoveNext()) + { + (uint ownerId, RegistrationRecord registration) = + _registrationEnumerator.Current; + if (registration.IsStatic + && (registration.SeedCellId & 0xFFFF0000u) == _prefix) + { + AddOwner(ownerId); + } + WorkUnits++; + return false; + } + _registrationEnumerator.Dispose(); + _registrationEnumerator = _staging._entityReg.GetEnumerator(); + _phase++; + return false; + case 2: + if (_registrationEnumerator.MoveNext()) + { + (uint ownerId, RegistrationRecord registration) = + _registrationEnumerator.Current; + if (registration.IsStatic + && (registration.SeedCellId & 0xFFFF0000u) == _prefix) + { + AddOwner(ownerId); + } + WorkUnits++; + return false; + } + _registrationEnumerator.Dispose(); + _ownerEnumerator = _owners.GetEnumerator(); + _phase++; + return false; + case 3: + if (_ownerEnumerator.MoveNext()) + { + uint ownerId = _ownerEnumerator.Current; + if (_staging.TryCaptureOwnerState( + ownerId, + out PreparedShadowOwnerState? state) + && state is not null) + { + _states.Add(state); + } + WorkUnits++; + return false; + } + _ownerEnumerator.Dispose(); + if (IsStable) + { + Prepared = new PreparedLandblockShadowReplacement( + _prefix, + _ownerIds, + _states); + } + _phase++; + return true; + default: + return true; + } + } + + private void AddOwner(uint ownerId) + { + if (_owners.Add(ownerId)) + _ownerIds.Add(ownerId); + } + + public void Dispose() + { + _expectedEnumerator?.Dispose(); + if (_phase is 1 or 2) + _registrationEnumerator.Dispose(); + if (_phase == 3) + _ownerEnumerator.Dispose(); + } + } + + private bool IsRetainedRefloodOwner(uint ownerId, uint landblockId) + { + if (!_entityReg.TryGetValue(ownerId, out RegistrationRecord? registration) + || _suspendedEntities.Contains(ownerId) + || (registration.IsStatic + && (registration.SeedCellId & 0xFFFF0000u) + == (landblockId & 0xFFFF0000u))) + { + return false; + } + return OwnerTouchesLandblock(ownerId, landblockId); + } + internal sealed class PreparedLandblockShadowReplacement { internal PreparedLandblockShadowReplacement( uint landblockPrefix, - uint[] ownerIds, - PreparedShadowOwnerState[] ownerStates, - Dictionary dynamicVersions) + IReadOnlyList ownerIds, + IReadOnlyList ownerStates) { LandblockPrefix = landblockPrefix; OwnerIds = ownerIds; OwnerStates = ownerStates; - DynamicVersions = dynamicVersions; } internal uint LandblockPrefix { get; } - internal uint[] OwnerIds { get; } - internal PreparedShadowOwnerState[] OwnerStates { get; } - internal Dictionary DynamicVersions { get; } + internal IReadOnlyList OwnerIds { get; } + internal IReadOnlyList OwnerStates { get; } } internal sealed record PreparedShadowOwnerState( uint EntityId, RegistrationRecord Registration, IReadOnlyList? Shapes, - uint[] CellIds, - PreparedShadowCellRows[] Rows, + List? CellIds, + IReadOnlyList Rows, bool Suspended, - uint[] WithdrawnPrefixes); + HashSet? WithdrawnPrefixes); internal sealed record PreparedShadowCellRows( uint CellId, @@ -1146,6 +1334,7 @@ public sealed class ShadowObjectRegistry _entityShapes.Clear(); _entityReg.Clear(); _ownerVersions.Clear(); + _mutationVersion = 0UL; _fallback = null; } diff --git a/src/AcDream.Core/World/Cells/CellGraph.cs b/src/AcDream.Core/World/Cells/CellGraph.cs index 9d9e82ea..021c6d46 100644 --- a/src/AcDream.Core/World/Cells/CellGraph.cs +++ b/src/AcDream.Core/World/Cells/CellGraph.cs @@ -19,7 +19,7 @@ namespace AcDream.Core.World.Cells; public sealed class CellGraph { private readonly ConcurrentDictionary _envCells = new(); - private readonly ConcurrentDictionary _terrain = new(); + private readonly ConcurrentDictionary _terrain = new(); /// The player's current cell — the render/lighting root. Written ONLY at the /// player chokepoint @@ -34,7 +34,8 @@ 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] = (terrain, worldOrigin); + => _terrain[landblockPrefix & 0xFFFF0000u] = + new CellGraphTerrain(terrain, worldOrigin); /// /// World origin (SW corner) of the landblock containing , @@ -137,46 +138,36 @@ public sealed class CellGraph var copy = new CellGraph { CurrCell = CurrCell }; foreach ((uint id, EnvCell cell) in _envCells) copy._envCells.TryAdd(id, cell); - foreach ((uint id, (TerrainSurface Terrain, Vector3 Origin) terrain) in - _terrain) + foreach ((uint id, CellGraphTerrain terrain) in _terrain) { copy._terrain.TryAdd(id, terrain); } return copy; } - internal PreparedCellGraphLandblock PrepareLandblockReplacement( - uint landblockId) - { - uint prefix = landblockId & 0xFFFF0000u; - KeyValuePair[] envCells = _envCells - .Where(static pair => (pair.Key & 0xFFFFu) >= 0x0100u) - .Where(pair => (pair.Key & 0xFFFF0000u) == prefix) - .OrderBy(static pair => pair.Key) - .ToArray(); - bool hasTerrain = _terrain.TryGetValue(prefix, out var terrain); - return new PreparedCellGraphLandblock( - prefix, - envCells, - hasTerrain, - terrain.Terrain, - terrain.Origin, - CurrCell?.Id ?? 0u); - } + internal LandblockReplacementBuilder CreateLandblockReplacementBuilder( + CellGraph staging, + uint landblockId) => new(this, staging, landblockId); internal void CommitLandblockReplacement( PreparedCellGraphLandblock replacement) { uint currentCellId = CurrCell?.Id ?? 0u; - RemoveLandblock(replacement.LandblockPrefix); + for (int index = 0; index < replacement.EnvCellIdsToRemove.Count; index++) + _envCells.TryRemove(replacement.EnvCellIdsToRemove[index], out _); if (replacement.HasTerrain) { - _terrain[replacement.LandblockPrefix] = ( - replacement.Terrain!, - replacement.Origin); + _terrain[replacement.LandblockPrefix] = replacement.Terrain!; } - foreach ((uint id, EnvCell cell) in replacement.EnvCells) + 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 @@ -188,13 +179,101 @@ public sealed class CellGraph : 0u; if (desiredCurrentCellId != 0u) CurrCell = GetVisible(desiredCurrentCellId); + else if ((currentCellId & 0xFFFF0000u) + == replacement.LandblockPrefix) + CurrCell = null; + } + + internal sealed class LandblockReplacementBuilder : IDisposable + { + private readonly CellGraph _active; + private readonly CellGraph _staging; + private readonly uint _prefix; + private readonly List> _envCells = new(); + private readonly HashSet _stagingIds = new(); + private readonly List _removeIds = new(); + private IEnumerator>? _enumerator; + private int _phase; + + internal LandblockReplacementBuilder( + CellGraph active, + CellGraph staging, + uint landblockId) + { + _active = active; + _staging = staging; + _prefix = landblockId & 0xFFFF0000u; + _enumerator = staging._envCells.GetEnumerator(); + } + + internal bool Advance() + { + if (_phase == 0) + { + if (_enumerator!.MoveNext()) + { + KeyValuePair pair = _enumerator.Current; + if ((pair.Key & 0xFFFF0000u) == _prefix + && (pair.Key & 0xFFFFu) >= 0x0100u) + { + _envCells.Add(pair); + _stagingIds.Add(pair.Key); + } + return false; + } + _enumerator.Dispose(); + _enumerator = _active._envCells.GetEnumerator(); + _phase = 1; + return false; + } + if (_phase == 1) + { + if (_enumerator!.MoveNext()) + { + uint id = _enumerator.Current.Key; + if ((id & 0xFFFF0000u) == _prefix + && (id & 0xFFFFu) >= 0x0100u + && !_stagingIds.Contains(id)) + { + _removeIds.Add(id); + } + return false; + } + _enumerator.Dispose(); + _enumerator = null; + bool hasTerrain = _staging._terrain.TryGetValue( + _prefix, + out var terrain); + Prepared = new PreparedCellGraphLandblock( + _prefix, + _removeIds, + _envCells, + hasTerrain, + terrain, + _staging.CurrCell?.Id ?? 0u); + _phase = 2; + } + return true; + } + + internal PreparedCellGraphLandblock? Prepared { get; private set; } + + public void Dispose() + { + _enumerator?.Dispose(); + _enumerator = null; + } } } internal sealed record PreparedCellGraphLandblock( uint LandblockPrefix, - KeyValuePair[] EnvCells, + IReadOnlyList EnvCellIdsToRemove, + IReadOnlyList> EnvCells, bool HasTerrain, - TerrainSurface? Terrain, - Vector3 Origin, + CellGraphTerrain? Terrain, uint CurrentCellId); + +internal sealed record CellGraphTerrain( + TerrainSurface Terrain, + Vector3 Origin); diff --git a/src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs b/src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs index 4baa3bdc..c903e04a 100644 --- a/src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs +++ b/src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs @@ -18,6 +18,77 @@ internal interface IHeadlessCollisionNeighborhood bool IsReady(uint fullCellId); } +internal static class HeadlessCollisionGenerationTransaction +{ + internal static RuntimeCollisionGenerationCommit Execute( + RuntimePhysicsState physics, + uint landblockId, + Action? afterAdmission, + Action stage) + { + ArgumentNullException.ThrowIfNull(physics); + ArgumentNullException.ThrowIfNull(stage); + + RuntimeCollisionAdmission admission = + physics.BeginCollisionAdmission(landblockId); + PreparedLandblockCollisionGeneration? prepared = null; + bool committed = false; + try + { + // This hook exists so the exact post-admission/pre-prepare failure + // boundary remains covered. Production does not install one. + afterAdmission?.Invoke(admission); + prepared = physics.PrepareCollisionGeneration(admission); + stage(admission, prepared); + + RuntimeCollisionOwnerCaptureStep ownerCapture; + do + { + ownerCapture = physics.AdvanceCollisionRetainedOwnerCapture( + admission, + prepared); + } + while (!ownerCapture.Completed); + foreach (uint ownerId in prepared.RetainedOwnerIds) + { + physics.RefreshCollisionRetainedOwner( + admission, + prepared, + ownerId); + } + RuntimeCollisionSealStep seal; + do + { + seal = physics.AdvanceCollisionGenerationSeal( + admission, + prepared); + } + while (!seal.Completed && !seal.Restarted); + if (!seal.Completed) + { + throw new InvalidOperationException( + "Headless collision owner set changed during synchronous sealing."); + } + + RuntimeCollisionGenerationCommit result = + physics.CommitCollisionGeneration(admission, prepared); + if (!result.Committed) + { + throw new InvalidOperationException( + "Headless collision generation changed during synchronous publication."); + } + committed = true; + return result; + } + finally + { + if (!committed) + physics.CancelCollisionGeneration(admission, prepared); + } + } +} + /// /// Per-session mutable collision publication over process-shared immutable /// DAT and pak inputs. Every session retains its own engine, data cache, @@ -172,81 +243,58 @@ internal sealed class HeadlessCollisionNeighborhood _content.PreparedCollision, landblock); - RuntimePhysicsState physics = - _runtime.EntityObjects.Physics; - RuntimeCollisionAdmission admission = - physics.BeginCollisionAdmission(landblockId); - using PreparedLandblockCollisionGeneration prepared = - physics.PrepareCollisionGeneration(admission); - prepared.SetAssetClosure( - [.. collisions.GfxObjIds], - [.. collisions.SetupIds]); - PhysicsDataCache cache = prepared.DataCache; - TerrainSurface terrain = - LandblockPhysicsContentBuilder.BuildTerrainSurface( - landblock, - _content.HeightTable.AsSpan()); - var cellSurfaces = new List(); - var portalPlanes = new List(); - LandblockPhysicsContentBuilder.PublishPreparedCells( - cache, - landblock, - collisions, - origin, - cellSurfaces, - portalPlanes); - LandblockPhysicsContentBuilder.CacheBuildings( - cache, - landblock, - terrain, - origin); - LandblockPhysicsContentBuilder.CachePreparedObjects( - cache, - collisions); - - try - { - physics.StageCollisionAssets( - admission, - prepared, - new RuntimeLandblockCollisionAssets( - landblockId, - terrain, - cellSurfaces, - portalPlanes, - origin.X, - origin.Y, - currentCellId)); - _ = LandblockPhysicsContentBuilder - .PublishStaticCollision( - prepared.Engine, + RuntimePhysicsState physics = _runtime.EntityObjects.Physics; + _ = HeadlessCollisionGenerationTransaction.Execute( + physics, + landblockId, + afterAdmission: null, + (admission, prepared) => + { + prepared.SetAssetClosure( + [.. collisions.GfxObjIds], + [.. collisions.SetupIds]); + PhysicsDataCache cache = prepared.DataCache; + TerrainSurface terrain = + LandblockPhysicsContentBuilder.BuildTerrainSurface( + landblock, + _content.HeightTable.AsSpan()); + var cellSurfaces = new List(); + var portalPlanes = new List(); + LandblockPhysicsContentBuilder.PublishPreparedCells( cache, landblock, collisions, + origin, + cellSurfaces, + portalPlanes); + LandblockPhysicsContentBuilder.CacheBuildings( + cache, + landblock, + terrain, origin); - foreach (uint ownerId in physics.CaptureCollisionDynamicOwners( - admission, - prepared)) - { - physics.RefreshCollisionDynamicOwner( + LandblockPhysicsContentBuilder.CachePreparedObjects( + cache, + collisions); + physics.StageCollisionAssets( admission, prepared, - ownerId); - } - RuntimeCollisionGenerationCommit commit = - physics.CommitCollisionGeneration(admission, prepared); - if (!commit.Committed) - { - throw new InvalidOperationException( - "Headless collision generation changed during synchronous publication."); - } - _resident.Add(CanonicalLandblock(landblockId)); - } - catch - { - _ = physics.WithdrawCollision(landblockId); - throw; - } + new RuntimeLandblockCollisionAssets( + landblockId, + terrain, + cellSurfaces, + portalPlanes, + origin.X, + origin.Y, + currentCellId)); + _ = LandblockPhysicsContentBuilder + .PublishStaticCollision( + prepared.Engine, + cache, + landblock, + collisions, + origin); + }); + _resident.Add(CanonicalLandblock(landblockId)); } private void RetireAll() diff --git a/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs b/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs index ba56b41b..e95dfcae 100644 --- a/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs +++ b/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs @@ -68,7 +68,7 @@ public readonly record struct RuntimeCollisionAcknowledgement( public readonly record struct RuntimeCollisionGenerationCommit( RuntimeCollisionAcknowledgement Acknowledgement, - uint[] DirtyDynamicOwnerIds) + uint[] DirtyRetainedOwnerIds) { public bool Committed => Acknowledgement.Ready; } @@ -87,7 +87,11 @@ internal sealed class PreparedLandblockCollisionGeneration : IDisposable { private readonly RuntimePhysicsState _owner; private readonly RuntimeCollisionAdmission _admission; - private readonly Dictionary _dynamicOwnerVersions = new(); + private readonly Dictionary _retainedOwnerVersions = new(); + private readonly List _retainedOwnerIds = new(); + private ShadowObjectRegistry.RetainedRefloodOwnerScan? _retainedOwnerScan; + private PhysicsEngine.LandblockReplacementBuilder? _sealBuilder; + private PhysicsEngine.PreparedPhysicsEngineLandblock? _sealedReplacement; private bool _disposed; internal PreparedLandblockCollisionGeneration( @@ -106,9 +110,13 @@ internal sealed class PreparedLandblockCollisionGeneration : IDisposable internal PhysicsEngine Engine { get; } internal uint[] GfxObjectIds { get; private set; } = Array.Empty(); internal uint[] SetupIds { get; private set; } = Array.Empty(); - internal IReadOnlyDictionary DynamicOwnerVersions => - _dynamicOwnerVersions; + internal IReadOnlyDictionary RetainedOwnerVersions => + _retainedOwnerVersions; internal bool IsDisposed => _disposed; + internal bool RetainedOwnerCaptureComplete { get; private set; } + internal ulong RetainedOwnerCaptureMutationVersion { get; private set; } + internal bool IsSealed => _sealedReplacement is not null; + internal ulong SealedShadowMutationVersion { get; private set; } internal bool Matches( RuntimePhysicsState owner, @@ -123,26 +131,155 @@ internal sealed class PreparedLandblockCollisionGeneration : IDisposable SetupIds = setupIds ?? throw new ArgumentNullException(nameof(setupIds)); } - internal void RefreshDynamicOwner(uint ownerId) + internal void RefreshRetainedOwner(uint ownerId) { EnsureUsable(); - bool retained = Engine.ShadowObjects.RefreshDynamicOwnerFrom( + bool retained = Engine.ShadowObjects.RefreshRetainedOwnerFrom( _owner.Engine.ShadowObjects, ownerId, _admission.LandblockId, out ulong version); if (retained) - _dynamicOwnerVersions[ownerId] = version; + _retainedOwnerVersions[ownerId] = version; else - _dynamicOwnerVersions.Remove(ownerId); + _retainedOwnerVersions.Remove(ownerId); } - internal uint[] FindDirtyDynamicOwners() + internal RuntimeCollisionOwnerCaptureStep AdvanceRetainedOwnerCapture() { EnsureUsable(); - return _owner.Engine.ShadowObjects.FindDirtyDynamicOwners( + if (RetainedOwnerCaptureComplete) + { + return new RuntimeCollisionOwnerCaptureStep( + Completed: true, + Restarted: false, + HasOwner: false, + OwnerId: 0u); + } + _retainedOwnerScan ??= _owner.Engine.ShadowObjects + .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); + if (step.Completed) + { + _retainedOwnerScan.Dispose(); + _retainedOwnerScan = null; + RetainedOwnerCaptureMutationVersion = step.SourceMutationVersion; + RetainedOwnerCaptureComplete = true; + } + return new RuntimeCollisionOwnerCaptureStep( + RetainedOwnerCaptureComplete, + Restarted: false, + step.HasOwner, + step.OwnerId); + } + + internal IReadOnlyList RetainedOwnerIds + { + get + { + EnsureUsable(); + if (!RetainedOwnerCaptureComplete) + { + throw new InvalidOperationException( + "Retained collision-owner capture is incomplete."); + } + return _retainedOwnerIds; + } + } + + internal void ResetRetainedOwnerCapture() + { + EnsureUsable(); + _retainedOwnerScan?.Dispose(); + _retainedOwnerScan = null; + _retainedOwnerIds.Clear(); + _retainedOwnerVersions.Clear(); + RetainedOwnerCaptureComplete = false; + RetainedOwnerCaptureMutationVersion = 0UL; + _sealedReplacement = null; + _sealBuilder?.Dispose(); + _sealBuilder = null; + SealedShadowMutationVersion = 0UL; + } + + internal RuntimeCollisionSealStep AdvanceSeal() + { + EnsureUsable(); + if (!RetainedOwnerCaptureComplete) + { + return new RuntimeCollisionSealStep( + Completed: false, + Restarted: true, + WorkUnits: 0); + } + if (_owner.Engine.ShadowObjects.MutationVersion + != RetainedOwnerCaptureMutationVersion) + { + ResetRetainedOwnerCapture(); + return new RuntimeCollisionSealStep( + Completed: false, + Restarted: true, + WorkUnits: 0); + } + if (_retainedOwnerVersions.Count != _retainedOwnerIds.Count) + { + throw new InvalidOperationException( + "Every retained collision owner must refresh before sealing."); + } + + _sealBuilder ??= _owner.Engine.CreateLandblockReplacementBuilder( + Engine, _admission.LandblockId, - _dynamicOwnerVersions); + GfxObjectIds, + SetupIds, + _retainedOwnerVersions); + int before = _sealBuilder.WorkUnits; + bool completed = _sealBuilder.Advance(); + int workUnits = _sealBuilder.WorkUnits - before; + if (!completed) + { + return new RuntimeCollisionSealStep( + Completed: false, + Restarted: false, + workUnits); + } + if (!_sealBuilder.IsStable || _sealBuilder.Prepared is null) + { + ResetRetainedOwnerCapture(); + return new RuntimeCollisionSealStep( + Completed: false, + Restarted: true, + workUnits); + } + _sealedReplacement = _sealBuilder.Prepared; + _sealBuilder.Dispose(); + _sealBuilder = null; + SealedShadowMutationVersion = + _owner.Engine.ShadowObjects.MutationVersion; + return new RuntimeCollisionSealStep( + Completed: true, + Restarted: false, + workUnits); + } + + internal PhysicsEngine.PreparedPhysicsEngineLandblock TakeSealedReplacement() + { + EnsureUsable(); + return _sealedReplacement + ?? throw new InvalidOperationException( + "Collision generation must be sealed before activation."); } internal void MarkCommitted() @@ -156,7 +293,14 @@ internal sealed class PreparedLandblockCollisionGeneration : IDisposable if (_disposed) return; Engine.Clear(); - _dynamicOwnerVersions.Clear(); + _retainedOwnerScan?.Dispose(); + _retainedOwnerScan = null; + _retainedOwnerIds.Clear(); + _retainedOwnerVersions.Clear(); + _sealBuilder?.Dispose(); + _sealBuilder = null; + _sealedReplacement = null; + SealedShadowMutationVersion = 0UL; _disposed = true; } @@ -167,6 +311,17 @@ internal sealed class PreparedLandblockCollisionGeneration : IDisposable } } +internal readonly record struct RuntimeCollisionOwnerCaptureStep( + bool Completed, + bool Restarted, + bool HasOwner, + uint OwnerId); + +internal readonly record struct RuntimeCollisionSealStep( + bool Completed, + bool Restarted, + int WorkUnits); + /// /// Presentation-free mutable physics world for one Runtime/session owner. /// Immutable prepared collision inputs may be supplied by a graphical or @@ -1001,6 +1156,43 @@ public sealed class RuntimePhysicsState : IDisposable stagingEngine); } + /// + /// Cancels only the named unpublished generation. The currently active + /// collision world is never withdrawn. A stale receipt may dispose its + /// own staging storage but cannot invalidate a newer admission. + /// + internal void CancelCollisionGeneration( + RuntimeCollisionAdmission admission, + PreparedLandblockCollisionGeneration? prepared = null) + { + EnsureNotDisposed(); + EnsureCollisionMutationThread(); + ArgumentNullException.ThrowIfNull(admission); + if (!ReferenceEquals(admission.Owner, this)) + { + throw new ArgumentException( + "Collision admission belongs to another Runtime.", + nameof(admission)); + } + if (prepared is not null && !prepared.Matches(this, admission)) + { + throw new ArgumentException( + "Prepared collision generation belongs to another admission.", + nameof(prepared)); + } + + prepared?.Dispose(); + if (_collisionAdmissions.TryGetValue( + admission.LandblockId, + out RuntimeCollisionAdmission? current) + && ReferenceEquals(current, admission)) + { + _collisionAdmissions.Remove(admission.LandblockId); + _collisionGenerations[admission.LandblockId] = checked( + admission.Generation + 1UL); + } + } + internal void StageCollisionAssets( RuntimeCollisionAdmission admission, PreparedLandblockCollisionGeneration prepared, @@ -1043,18 +1235,17 @@ public sealed class RuntimePhysicsState : IDisposable admission.AssetsPrepared = true; } - internal uint[] CaptureCollisionDynamicOwners( + internal RuntimeCollisionOwnerCaptureStep AdvanceCollisionRetainedOwnerCapture( RuntimeCollisionAdmission admission, PreparedLandblockCollisionGeneration prepared) { ValidateAdmission(admission); EnsureCollisionMutationThread(); ValidatePreparedGeneration(admission, prepared); - return Engine.ShadowObjects.CaptureDynamicRefloodOwnersForLandblock( - admission.LandblockId); + return prepared.AdvanceRetainedOwnerCapture(); } - internal void RefreshCollisionDynamicOwner( + internal void RefreshCollisionRetainedOwner( RuntimeCollisionAdmission admission, PreparedLandblockCollisionGeneration prepared, uint ownerId) @@ -1062,7 +1253,32 @@ public sealed class RuntimePhysicsState : IDisposable ValidateAdmission(admission); EnsureCollisionMutationThread(); ValidatePreparedGeneration(admission, prepared); - prepared.RefreshDynamicOwner(ownerId); + prepared.RefreshRetainedOwner(ownerId); + } + + internal void RestartCollisionRetainedOwnerCapture( + RuntimeCollisionAdmission admission, + PreparedLandblockCollisionGeneration prepared) + { + ValidateAdmission(admission); + EnsureCollisionMutationThread(); + ValidatePreparedGeneration(admission, prepared); + prepared.ResetRetainedOwnerCapture(); + } + + internal RuntimeCollisionSealStep AdvanceCollisionGenerationSeal( + RuntimeCollisionAdmission admission, + PreparedLandblockCollisionGeneration prepared) + { + ValidateAdmission(admission); + EnsureCollisionMutationThread(); + ValidatePreparedGeneration(admission, prepared); + if (!admission.AssetsPrepared) + { + throw new InvalidOperationException( + "Collision generation cannot seal before its assets are prepared."); + } + return prepared.AdvanceSeal(); } internal RuntimeCollisionGenerationCommit CommitCollisionGeneration( @@ -1083,38 +1299,25 @@ public sealed class RuntimePhysicsState : IDisposable "Collision generation has already completed."); } - uint[] dirtyOwners = prepared.FindDirtyDynamicOwners(); - if (dirtyOwners.Length != 0) + if (!prepared.IsSealed) { + throw new InvalidOperationException( + "Collision generation cannot activate before sealing."); + } + if (Engine.ShadowObjects.MutationVersion + != prepared.SealedShadowMutationVersion) + { + prepared.ResetRetainedOwnerCapture(); return new RuntimeCollisionGenerationCommit( new RuntimeCollisionAcknowledgement( admission.LandblockId, admission.Generation, Engine.IsLandblockTerrainResident(admission.LandblockId), Ready: false), - dirtyOwners); + Array.Empty()); } - PhysicsEngine.PreparedPhysicsEngineLandblock replacement = - Engine.PrepareLandblockReplacement( - prepared.Engine, - admission.LandblockId, - prepared.GfxObjectIds, - prepared.SetupIds, - prepared.DynamicOwnerVersions); - if (!Engine.ValidateLandblockReplacement(replacement)) - { - dirtyOwners = prepared.FindDirtyDynamicOwners(); - return new RuntimeCollisionGenerationCommit( - new RuntimeCollisionAcknowledgement( - admission.LandblockId, - admission.Generation, - Engine.IsLandblockTerrainResident(admission.LandblockId), - Ready: false), - dirtyOwners); - } - - Engine.CommitLandblockReplacement(replacement); + Engine.CommitLandblockReplacement(prepared.TakeSealedReplacement()); admission.Completed = true; _collisionAdmissions.Remove(admission.LandblockId); prepared.MarkCommitted(); diff --git a/tests/AcDream.App.Tests/Streaming/LandblockPhysicsPublisherTests.cs b/tests/AcDream.App.Tests/Streaming/LandblockPhysicsPublisherTests.cs index c29f2c04..b1d61c3b 100644 --- a/tests/AcDream.App.Tests/Streaming/LandblockPhysicsPublisherTests.cs +++ b/tests/AcDream.App.Tests/Streaming/LandblockPhysicsPublisherTests.cs @@ -976,6 +976,43 @@ public sealed class LandblockPhysicsPublisherTests new Dictionary()); } + [Fact] + public void ReloadedSeamAtomicallyRestoresAdjacentStaticAndClearsRepairMarker() + { + var fixture = Fixture(); + CacheCylinderSetup(fixture.Cache); + WorldEntity neighbor = CylinderEntity( + 0x80AAB401u, + new Vector3(192.25f, 12f, 0f)); + Publish(fixture.Publisher, Build(FirstLandblock)); + Publish(fixture.Publisher, Build(AdjacentLandblock, [neighbor])); + Assert.True(fixture.Engine.ShadowObjects.HasOwnerRowsInLandblock( + neighbor.Id, + FirstLandblock)); + + fixture.Publisher.RemoveLandblock(FirstLandblock); + + Assert.Equal(1, fixture.Engine.ShadowObjects.WithdrawnPrefixMarkerCount); + Assert.False(fixture.Engine.ShadowObjects.HasOwnerRowsInLandblock( + neighbor.Id, + FirstLandblock)); + LandblockPhysicsPublication pending = Begin( + fixture.Publisher, + Build(FirstLandblock)); + Assert.Equal(1, fixture.Engine.ShadowObjects.WithdrawnPrefixMarkerCount); + + fixture.Publisher.CompletePublication(pending); + + Assert.Equal(0, fixture.Engine.ShadowObjects.WithdrawnPrefixMarkerCount); + Assert.True(fixture.Engine.ShadowObjects.HasOwnerRowsInLandblock( + neighbor.Id, + FirstLandblock)); + Assert.True(fixture.Engine.ShadowObjects.HasOwnerRowsInLandblock( + neighbor.Id, + AdjacentLandblock)); + Assert.Equal(1, fixture.Engine.ShadowObjects.RetainedRegistrationCount); + } + private static LandblockCollisionBuild FlatCellClosure( PhysicsDatBundle bundle, uint envCellId) diff --git a/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs b/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs index 32823794..ffb9e650 100644 --- a/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs +++ b/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs @@ -12,6 +12,7 @@ using AcDream.Headless.Platform; using AcDream.Runtime; using AcDream.Runtime.Entities; using AcDream.Runtime.Gameplay; +using AcDream.Runtime.Physics; using AcDream.Runtime.Session; using AcDream.Runtime.World; @@ -373,6 +374,72 @@ public sealed class HeadlessSessionHostTests Assert.Equal(2, collision.CenterCount); } + [Fact] + public void CollisionTransactionCancelsPostAdmissionFaultWithoutWithdrawingActiveWorld() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimePhysicsState physics = lifetime.Physics; + const uint landblockId = 0xA9B4FFFFu; + _ = HeadlessCollisionGenerationTransaction.Execute( + physics, + landblockId, + afterAdmission: null, + (admission, prepared) => + physics.StageCollisionAssets( + admission, + prepared, + CollisionAssets(landblockId, 10f))); + + Assert.Throws(() => + HeadlessCollisionGenerationTransaction.Execute( + physics, + landblockId, + _ => throw new FixtureCollisionPublicationException(), + (_, _) => throw new InvalidOperationException( + "Staging must not run after the injected admission fault."))); + + Assert.Equal(10f, physics.Engine.SampleTerrainZ(1f, 1f)); + RuntimePhysicsOwnershipSnapshot ownership = physics.CaptureOwnership(); + Assert.Equal(1, ownership.LandblockCount); + Assert.Equal(0, ownership.CollisionAdmissionCount); + } + + [Fact] + public void CollisionTransactionCancelsStagingFaultWithoutWithdrawingActiveWorld() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimePhysicsState physics = lifetime.Physics; + const uint landblockId = 0xA9B4FFFFu; + _ = HeadlessCollisionGenerationTransaction.Execute( + physics, + landblockId, + afterAdmission: null, + (admission, prepared) => + physics.StageCollisionAssets( + admission, + prepared, + CollisionAssets(landblockId, 10f))); + + Assert.Throws(() => + HeadlessCollisionGenerationTransaction.Execute( + physics, + landblockId, + afterAdmission: null, + (admission, prepared) => + { + physics.StageCollisionAssets( + admission, + prepared, + CollisionAssets(landblockId, 25f)); + throw new FixtureCollisionPublicationException(); + })); + + Assert.Equal(10f, physics.Engine.SampleTerrainZ(1f, 1f)); + RuntimePhysicsOwnershipSnapshot ownership = physics.CaptureOwnership(); + Assert.Equal(1, ownership.LandblockCount); + Assert.Equal(0, ownership.CollisionAdmissionCount); + } + private static HeadlessSessionDescriptor Descriptor( HeadlessCredentialProviderKind provider = HeadlessCredentialProviderKind.Environment, @@ -422,6 +489,25 @@ public sealed class HeadlessSessionHostTests runtime.MovementOwner.Controller = controller; } + private static RuntimeLandblockCollisionAssets CollisionAssets( + uint landblockId, + float terrainHeight) + { + var heights = new byte[81]; + var table = new float[256]; + table[0] = terrainHeight; + return new RuntimeLandblockCollisionAssets( + landblockId, + new TerrainSurface(heights, table), + Array.Empty(), + Array.Empty(), + 0f, + 0f, + 0u); + } + + private sealed class FixtureCollisionPublicationException : Exception; + private static void AddFlatLandblock(PhysicsEngine engine) { var heights = new byte[81]; diff --git a/tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsStateTests.cs b/tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsStateTests.cs index 75ce462f..e4e60922 100644 --- a/tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsStateTests.cs +++ b/tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsStateTests.cs @@ -322,7 +322,7 @@ public sealed class RuntimePhysicsStateTests prepared, CollisionAssets(0xA9B4FFFFu)); RuntimeCollisionGenerationCommit commit = - first.Physics.CommitCollisionGeneration(newer, prepared); + CommitPrepared(first.Physics, newer, prepared); RuntimeCollisionAcknowledgement completed = commit.Acknowledgement; Assert.True(commit.Committed); @@ -332,7 +332,7 @@ public sealed class RuntimePhysicsStateTests 0, first.Physics.CaptureOwnership().CollisionAdmissionCount); Assert.Throws(() => - first.Physics.CommitCollisionGeneration(newer, prepared)); + CommitPrepared(first.Physics, newer, prepared)); RuntimeCollisionAcknowledgement withdrawn = first.Physics.WithdrawCollision(0xA9B4FFFFu); @@ -355,7 +355,7 @@ public sealed class RuntimePhysicsStateTests firstAdmission, first, CollisionAssets(0xA9B4FFFFu, terrainHeight: 10f)); - Assert.True(physics.CommitCollisionGeneration( + Assert.True(CommitPrepared(physics, firstAdmission, first).Committed); } @@ -375,7 +375,7 @@ public sealed class RuntimePhysicsStateTests Assert.Equal(0, notifications); RuntimeCollisionGenerationCommit committed = - physics.CommitCollisionGeneration( + CommitPrepared(physics, replacementAdmission, replacement); @@ -401,7 +401,11 @@ public sealed class RuntimePhysicsStateTests RuntimeCollisionAdmission current = physics.BeginCollisionAdmission(0xA9B4FFFFu); Assert.Throws(() => - physics.CommitCollisionGeneration(stale, preparedStale)); + CommitPrepared(physics, stale, preparedStale)); + physics.CancelCollisionGeneration(stale, preparedStale); + Assert.Equal( + 1, + physics.CaptureOwnership().CollisionAdmissionCount); Assert.False(physics.Engine.IsLandblockTerrainResident(0xA9B4FFFFu)); using PreparedLandblockCollisionGeneration preparedCurrent = @@ -410,14 +414,14 @@ public sealed class RuntimePhysicsStateTests current, preparedCurrent, CollisionAssets(0xA9B4FFFFu, terrainHeight: 20f)); - Assert.True(physics.CommitCollisionGeneration( + Assert.True(CommitPrepared(physics, current, preparedCurrent).Committed); Assert.Equal(20f, physics.Engine.SampleTerrainZ(1f, 1f)); } [Fact] - public void MovingDynamicOwnerDuringStagingMustRefreshBeforeCommit() + public void WithdrawnOwnerStateChangeRejectsSealUntilRefreshed() { using var lifetime = new RuntimeEntityObjectLifetime(); RuntimePhysicsState physics = lifetime.Physics; @@ -430,7 +434,7 @@ public sealed class RuntimePhysicsStateTests initialAdmission, initial, CollisionAssets(0x0101FFFFu, terrainHeight: 5f)); - Assert.True(physics.CommitCollisionGeneration( + Assert.True(CommitPrepared(physics, initialAdmission, initial).Committed); } @@ -445,6 +449,9 @@ public sealed class RuntimePhysicsStateTests 0x0101FFFFu, seedCellId: 0x01010001u, isStatic: false); + physics.Engine.ShadowObjects.RemoveLandblock(0x0101FFFFu); + Assert.Equal(0, physics.Engine.ShadowObjects.TotalRegistered); + Assert.Equal(1, physics.Engine.ShadowObjects.RetainedRegistrationCount); RuntimeCollisionAdmission admission = physics.BeginCollisionAdmission(0x0101FFFFu); @@ -454,33 +461,125 @@ public sealed class RuntimePhysicsStateTests admission, prepared, CollisionAssets(0x0101FFFFu, terrainHeight: 15f)); - uint owner = Assert.Single(physics.CaptureCollisionDynamicOwners( - admission, - prepared)); - physics.RefreshCollisionDynamicOwner(admission, prepared, owner); - physics.Engine.ShadowObjects.UpdatePosition( - owner, - new Vector3(30f, 10f, 0f), - Quaternion.Identity, - 0f, - 0f, - 0x0101FFFFu, - seedCellId: 0x01010009u); + 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(owner, Assert.Single(rejected.DirtyDynamicOwnerIds)); Assert.Equal(5f, physics.Engine.SampleTerrainZ(1f, 1f)); - physics.RefreshCollisionDynamicOwner(admission, prepared, owner); + Assert.Equal(owner, Assert.Single(SealPrepared( + physics, + admission, + prepared))); Assert.True(physics.CommitCollisionGeneration( admission, prepared).Committed); Assert.Equal(15f, physics.Engine.SampleTerrainZ(1f, 1f)); - Assert.Contains( - physics.Engine.ShadowObjects.GetObjectsInCell(0x01010009u), - entry => entry.EntityId == owner); + ShadowEntry restored = Assert.Single( + physics.Engine.ShadowObjects.AllEntriesForDebug()); + Assert.Equal(owner, restored.EntityId); + Assert.Equal(0x14u, restored.State); + } + + [Fact] + public void DenseReplacementSealsOneWorkUnitPerStepAndActivatesWithoutAllocation() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + RuntimePhysicsState physics = lifetime.Physics; + const uint landblockId = 0x0101FFFFu; + RuntimeCollisionAdmission initialAdmission = + physics.BeginCollisionAdmission(landblockId); + using (PreparedLandblockCollisionGeneration initial = + physics.PrepareCollisionGeneration(initialAdmission)) + { + physics.StageCollisionAssets( + initialAdmission, + initial, + CollisionAssets(landblockId, terrainHeight: 5f)); + Assert.True(CommitPrepared( + physics, + initialAdmission, + initial).Committed); + } + + const int ownerCount = 256; + for (uint index = 0; index < ownerCount; index++) + { + physics.Engine.ShadowObjects.Register( + entityId: 1000u + index, + gfxObjId: 0x01000001u, + worldPos: new Vector3( + 8f + (index % 16u) * 0.25f, + 8f + (index / 16u) * 0.25f, + 0f), + rotation: Quaternion.Identity, + radius: 0.5f, + worldOffsetX: 0f, + worldOffsetY: 0f, + landblockId, + seedCellId: 0x01010001u, + isStatic: false); + } + + RuntimeCollisionAdmission admission = + physics.BeginCollisionAdmission(landblockId); + using PreparedLandblockCollisionGeneration prepared = + physics.PrepareCollisionGeneration(admission); + physics.StageCollisionAssets( + admission, + prepared, + CollisionAssets(landblockId, terrainHeight: 15f)); + + int captureSteps = 0; + RuntimeCollisionOwnerCaptureStep capture; + do + { + capture = physics.AdvanceCollisionRetainedOwnerCapture( + admission, + prepared); + captureSteps++; + } + while (!capture.Completed); + Assert.True(captureSteps >= ownerCount); + Assert.Equal(ownerCount, prepared.RetainedOwnerIds.Count); + foreach (uint ownerId in prepared.RetainedOwnerIds) + { + physics.RefreshCollisionRetainedOwner( + admission, + prepared, + ownerId); + } + + int sealSteps = 0; + int workUnits = 0; + RuntimeCollisionSealStep seal; + do + { + seal = physics.AdvanceCollisionGenerationSeal( + admission, + prepared); + sealSteps++; + Assert.InRange(seal.WorkUnits, 0, 1); + workUnits += seal.WorkUnits; + } + while (!seal.Completed); + Assert.False(seal.Restarted); + Assert.True(sealSteps > ownerCount); + Assert.True(workUnits > ownerCount); + + _ = 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(15f, physics.Engine.SampleTerrainZ(1f, 1f)); + Assert.Equal(ownerCount, physics.Engine.ShadowObjects.TotalRegistered); } [Fact] @@ -496,7 +595,7 @@ public sealed class RuntimePhysicsStateTests admission, prepared, CollisionAssets(0x0101FFFFu, terrainHeight: 12f)); - Assert.Empty(physics.CaptureCollisionDynamicOwners(admission, prepared)); + Assert.Empty(SealPrepared(physics, admission, prepared)); physics.Engine.ShadowObjects.Register( 77u, @@ -512,16 +611,17 @@ public sealed class RuntimePhysicsStateTests RuntimeCollisionGenerationCommit spawned = physics.CommitCollisionGeneration(admission, prepared); Assert.False(spawned.Committed); - Assert.Equal(77u, Assert.Single(spawned.DirtyDynamicOwnerIds)); - physics.RefreshCollisionDynamicOwner(admission, prepared, 77u); + 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.Equal(77u, Assert.Single(deleted.DirtyDynamicOwnerIds)); - physics.RefreshCollisionDynamicOwner(admission, prepared, 77u); + Assert.Empty(SealPrepared(physics, admission, prepared)); Assert.True(physics.CommitCollisionGeneration( admission, prepared).Committed); @@ -546,7 +646,7 @@ public sealed class RuntimePhysicsStateTests admission, prepared, CollisionAssets(0x0101FFFFu)); - _ = lifetime.Physics.CommitCollisionGeneration(admission, prepared); + _ = CommitPrepared(lifetime.Physics, admission, prepared); lifetime.Physics.Engine.ShadowObjects.Register( entityId: record.LocalEntityId!.Value, gfxObjId: 0x01000001u, @@ -930,6 +1030,49 @@ public sealed class RuntimePhysicsStateTests Assert.Equal(record.SpatialAuthorityVersion, observed.SpatialAuthorityVersion); } + private static RuntimeCollisionGenerationCommit CommitPrepared( + RuntimePhysicsState physics, + RuntimeCollisionAdmission admission, + PreparedLandblockCollisionGeneration prepared) + { + _ = SealPrepared(physics, admission, prepared); + return physics.CommitCollisionGeneration(admission, prepared); + } + + private static uint[] SealPrepared( + RuntimePhysicsState physics, + RuntimeCollisionAdmission admission, + PreparedLandblockCollisionGeneration prepared) + { + while (true) + { + while (!physics.AdvanceCollisionRetainedOwnerCapture( + admission, + prepared).Completed) + { + } + foreach (uint ownerId in prepared.RetainedOwnerIds) + { + physics.RefreshCollisionRetainedOwner( + admission, + prepared, + ownerId); + } + RuntimeCollisionSealStep seal; + do + { + seal = physics.AdvanceCollisionGenerationSeal( + admission, + prepared); + Assert.InRange(seal.WorkUnits, 0, 1); + } + while (!seal.Completed && !seal.Restarted); + if (seal.Completed) + break; + } + return [.. prepared.RetainedOwnerIds]; + } + private static RuntimeLandblockCollisionAssets CollisionAssets( uint landblockId, float terrainHeight = 0f)