fix(physics): seal collision generations before activation

This commit is contained in:
Erik 2026-07-31 15:53:05 +02:00
parent be94bc9b06
commit d94145e6b8
15 changed files with 1556 additions and 410 deletions

View file

@ -488,14 +488,19 @@ What exists and is active:
- Landblock collision activation is generation-owned by - Landblock collision activation is generation-owned by
`RuntimePhysicsState`. Graphical and no-window hosts populate a private `RuntimePhysicsState`. Graphical and no-window hosts populate a private
`PreparedLandblockCollisionGeneration` over bounded cursors; its cache, `PreparedLandblockCollisionGeneration` over bounded cursors; its cache,
`CellGraph`, engine landblock, buildings, static shadows, and dynamic-owner `CellGraph`, engine landblock, buildings, static shadows, and retained-owner
refloods are never visible through the borrowed live engine. Runtime validates refloods are never visible through the borrowed live engine. Retained owners
the exact admission plus every affected dynamic shadow-owner version, seals comprise every non-suspended dynamic or adjacent-root static touching the
the complete replacement, and commits it synchronously on the one update target prefix (including a withdrawn repair marker); target-root statics come
thread before emitting `CollisionGenerationCommitted`. A moving, spawned, or from the authored replacement. Runtime mutation-gates their exact capture,
deleted owner makes the generation not-ready and returns only that dirty owner refreshes each through the host work meter, and builds every cache/graph/
to the cursor-budgeted refresh phase. Thus readers see the complete old shadow replacement list through one-work-unit seal cursors. The final update-
generation or complete new generation, never a mixed cell/cache/shadow world. 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 - `ShadowObjectRegistry` gives movement a per-cell broadphase over nearby
objects and buildings. Streaming reflood is structurally part of the Runtime objects and buildings. Streaming reflood is structurally part of the Runtime
collision-generation commit; there is no independent post-publication collision-generation commit; there is no independent post-publication

View file

@ -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-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-2 | Async readiness gates replace retail's synchronous destination cell load. **#229 refinement (2026-07-20):** login and F751 portal-space exit now share `WorldRevealReadinessBarrier`, so neither path can expose the normal viewport until the same render-publication, composite-texture, and collision domains converge. A hydratable indoor claim requires its owning Near-tier static/EnvCell mesh set, destination composites, and exact EnvCell physics (`IsSpawnCellReady`); an outdoor claim requires those render domains plus terrain/collision residency for the required Near ring. Hard-recenter generations and tier-aware completion application prevent stale overlapping loads/unloads or Far/Near jobs from opening or erasing the gate; mesh upload remains separate from balanced landblock ownership. Claims beyond NumCells still take the loud unhydratable-placement path. `RuntimeWorldTransitState` owns the shared reveal generation, accepted readiness, transit correlation, and exact generation/cell-scoped host-acknowledgement suffix. `WorldRevealCoordinator` is a graphical adapter holding only App resource receipts; normalized Runtime checkpoints observe ownership without defining another readiness path. **Slice E3 refinement (2026-07-24):** the same generation now publishes an immediate `WorldGenerationQuiescence` edge: old-world drawing/spatial queries, simulation/effect clocks, reconciliation, targeting, and 3-D audio stop while retained physical teardown advances through metered cursors and destination network/UI/streaming/readiness remain live. **Slice E4 refinement (2026-07-24):** accepted render/physics/static publication may span update frames through retained exact cursors, but reveal still consumes only the completed spatial/render-ready generation; building and EnvCell snapshots remain invisible until complete and the final spatial identity swap stays observer-atomic. **Slice E5 refinement (2026-07-24):** the reveal generation owns one exact destination reservation across every typed budget dimension. Stale completion cannot consume or clear its replacement, and hydratable incomplete content is never force-revealed; portal transit retains the DAT tunnel and centered retail wait cue until readiness converges. The hold→materialize→regain-control lifecycle remains owned by `TeleportAnimSequencer`. | `src/AcDream.Runtime/World/RuntimeWorldTransitState.cs`; `src/AcDream.App/Streaming/WorldRevealCoordinator.cs`; `src/AcDream.App/Streaming/WorldGenerationQuiescence.cs`; `src/AcDream.App/Streaming/WorldRevealReadinessBarrier.cs`; `src/AcDream.App/Streaming/StreamingOriginRecenterCoordinator.cs`; `src/AcDream.App/Streaming/LandblockPresentationPipeline.cs`; `src/AcDream.App/Streaming/StreamingController.cs`; `src/AcDream.App/Rendering/PortalTunnelPresentation.cs`; `src/AcDream.App/UI/PortalWaitNoticeController.cs`; `src/AcDream.App/Streaming/GpuWorldState.cs` (`IsRenderReady`); `src/AcDream.App/Rendering/Wb/LandblockSpawnAdapter.cs`; `src/AcDream.Core/Physics/PhysicsEngine.cs` (`IsSpawnCellReady`, `IsNeighborhoodTerrainResident`) | This is the asynchronous equivalent of retail leaving `SmartBox::position_update_complete` false while `CellManager::blocking_for_cells` is set: neither initial login nor portal arrival may reveal or continue simulating an old/partial collision world, a terrain-only Far shell, or a published-but-not-drawable GPU landblock. Indoor does not require a terrain heightmap, only the owning render landblock and exact EnvCell. | Gate opens early → grey/untextured first login or portal reveal, free-fall, wrong-cell rooting, missing scenery, or a still-active old generation; predicate never satisfies (streamer/DAT/upload failure) → login remains behind the world render gate, while portal transit remains in the authored tunnel and presents the centered wait cue after five seconds. | `SmartBox::UseTime` 0x00455410; `gmSmartBoxUI::UseTime` 0x004D6E30; `gmSmartBoxUI::EndTeleportAnimation` 0x004D65A0 |
| AD-5 | Outdoor `point_in_cell` is an identity compare against the global XY-column cell from `LandDefs.AdjustToOutside` (no per-cell containment test) | `src/AcDream.Core/Physics/CellTransit.cs:865` | Landcells are disjoint 24 m columns — identity-compare against the column under the sphere centre is exactly equivalent to retail's per-candidate test | If block-origin/lcoord math is wrong at a landblock seam, the compare silently never matches — outdoor membership freezes at boundaries (the pre-#106 symptom) | `find_cell_list` pick pc:308788-308825; `CLandCell::point_in_cell` (get_block_offset pc:308804) | | AD-5 | Outdoor `point_in_cell` is an identity compare against the global XY-column cell from `LandDefs.AdjustToOutside` (no per-cell containment test) | `src/AcDream.Core/Physics/CellTransit.cs:865` | Landcells are disjoint 24 m columns — identity-compare against the column under the sphere centre is exactly equivalent to retail's per-candidate test | If block-origin/lcoord math is wrong at a landblock seam, the compare silently never matches — outdoor membership freezes at boundaries (the pre-#106 symptom) | `find_cell_list` pick pc:308788-308825; `CLandCell::point_in_cell` (get_block_offset pc:308804) |
| ~~AD-6~~ | **RETIRED 2026-07-31 (placement/streaming Slice 3B).** Cell/cache/topology/building/static-shadow publication plus 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-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-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 | | 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 |

View file

@ -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 GfxObj/Setup catalogs are not copied; the accepted build's exact closure is
populated by the existing cursors. populated by the existing cursors.
3. App and Headless publish terrain, EnvCells, topology, buildings, prepared 3. App and Headless publish terrain, EnvCells, topology, buildings, prepared
collision assets, static owners, and retained dynamic-owner cell sets only collision assets, and target-root static owners only into that private
into that private generation. generation.
4. Each dynamic owner refresh captures its exact `ShadowObjectRegistry` 4. A mutation-stable cursor captures every non-suspended owner that touches —
mutation version. Movement, state/payload change, spawn, suspension, or or has a withdrawn repair marker for — the target prefix. That includes
deletion changes that version. live dynamic owners and statics rooted in an adjacent landblock. Only a
5. `CommitCollisionGeneration` validates the admission, exact affected-owner target-root static is omitted, because the authored replacement supersedes
set, and all captured versions. If any owner is dirty, it returns the sorted it. Each retained owner refresh captures its exact
dirty IDs without touching the active world; App advances those IDs through `ShadowObjectRegistry` mutation version; a rowless withdrawn owner therefore
its existing work meter and retries. remains freshness-gated when state or payload changes.
6. Once fresh, Runtime precomputes the replacement arrays and synchronously 5. Explicit one-work-unit cursors build the complete replacement before the
replaces the landblock's cache/graph/engine/building/static and dynamic activation frame: requested global collision records, cells/topology,
shadow state on the same update thread. Only after the complete replacement buildings, cell graph removals, affected static owners, retained-owner
does it emit `CollisionGenerationCommitted` and a ready acknowledgement. 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 The stable borrowed `PhysicsEngine` and `PhysicsDataCache` object identities do
not change. Presentation and no-window hosts use the same Runtime transaction. not change. Presentation and no-window hosts use the same Runtime transaction.
@ -47,6 +53,9 @@ shadow state.
## Failure and lifetime rules ## Failure and lifetime rules
- A newer admission invalidates an older prepared generation. - 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 - Demotion, withdrawal, reset, and disposal invalidate the admission before
changing the active generation. changing the active generation.
- Disposing a stale/cancelled prepared generation clears only its private - 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; - stale admission replacement has no active-world side effect;
- movement during staging rejects, refreshes only the dirty owner, and then - movement during staging rejects, refreshes only the dirty owner, and then
installs its latest cell set; 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; - 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; - graphical and no-window publishers use the same Runtime transaction;
- removal and terminal teardown converge the active ownership ledger. - removal and terminal teardown converge the active ownership ledger.

View file

@ -12,11 +12,15 @@ as "delete everything and start over." A partial retail transition port exists:
- `PhysicsDataCache` loads GfxObj, Setup, and CellStruct physics data. - `PhysicsDataCache` loads GfxObj, Setup, and CellStruct physics data.
- Landblock streaming never edits the live collision world incrementally. - Landblock streaming never edits the live collision world incrementally.
`PreparedLandblockCollisionGeneration` owns an off-side cache, CellGraph, `PreparedLandblockCollisionGeneration` owns an off-side cache, CellGraph,
engine snapshot, buildings, statics, and versioned dynamic-owner refloods; engine snapshot, buildings, statics, and versioned retained-owner refloods.
`RuntimePhysicsState.CommitCollisionGeneration` validates freshness and Retained means every non-suspended dynamic or adjacent-root static touching
activates the complete generation on the single update thread. A dirty live (or withdrawn from) the target prefix; only authored target-root statics are
owner returns to the App cursor for refresh. The old generation remains superseded. All scans and replacement construction run through bounded
queryable until that commit. See 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`. `docs/research/2026-07-31-atomic-collision-generation.md`.
- `ShadowObjectRegistry` gives the resolver a broadphase over nearby objects. - `ShadowObjectRegistry` gives the resolver a broadphase over nearby objects.
- `TerrainSurface` uses triangle-aware terrain contact. - `TerrainSurface` uses triangle-aware terrain contact.

View file

@ -21,6 +21,7 @@ public sealed class LandblockPhysicsPublication : IDisposable
uint currentCellId, uint currentCellId,
BuildingInfo[] buildings, BuildingInfo[] buildings,
uint[] priorStaticOwnerIds, uint[] priorStaticOwnerIds,
RuntimePhysicsState physics,
RuntimeCollisionAdmission collisionAdmission, RuntimeCollisionAdmission collisionAdmission,
PreparedLandblockCollisionGeneration preparedGeneration) PreparedLandblockCollisionGeneration preparedGeneration)
{ {
@ -30,6 +31,7 @@ public sealed class LandblockPhysicsPublication : IDisposable
CurrentCellId = currentCellId; CurrentCellId = currentCellId;
Buildings = buildings; Buildings = buildings;
PriorStaticOwnerIds = priorStaticOwnerIds; PriorStaticOwnerIds = priorStaticOwnerIds;
Physics = physics;
CollisionAdmission = collisionAdmission; CollisionAdmission = collisionAdmission;
PreparedGeneration = preparedGeneration; PreparedGeneration = preparedGeneration;
} }
@ -39,6 +41,7 @@ public sealed class LandblockPhysicsPublication : IDisposable
internal uint CurrentCellId { get; } internal uint CurrentCellId { get; }
internal BuildingInfo[] Buildings { get; } internal BuildingInfo[] Buildings { get; }
internal uint[] PriorStaticOwnerIds { get; } internal uint[] PriorStaticOwnerIds { get; }
internal RuntimePhysicsState Physics { get; }
internal RuntimeCollisionAdmission CollisionAdmission { get; } internal RuntimeCollisionAdmission CollisionAdmission { get; }
internal PreparedLandblockCollisionGeneration PreparedGeneration { get; } internal PreparedLandblockCollisionGeneration PreparedGeneration { get; }
internal PhysicsDataCache StagingCache => PreparedGeneration.DataCache; internal PhysicsDataCache StagingCache => PreparedGeneration.DataCache;
@ -63,16 +66,19 @@ public sealed class LandblockPhysicsPublication : IDisposable
internal int CylinderOwnerCount { get; set; } internal int CylinderOwnerCount { get; set; }
internal int NoCollisionCount { get; set; } internal int NoCollisionCount { get; set; }
internal int SceneryTried { get; set; } internal int SceneryTried { get; set; }
internal uint[]? RefloodOwnerIds { get; set; } internal IReadOnlyList<uint>? RefloodOwnerIds { get; set; }
internal int RefloodCursor { get; set; } internal int RefloodCursor { get; set; }
internal bool RefloodCommitted { get; set; } internal bool RefloodCommitted { get; set; }
internal bool SealCommitted { get; set; }
internal bool BeginCommitted { get; set; } internal bool BeginCommitted { get; set; }
internal bool CompletionCommitted { get; set; } internal bool CompletionCommitted { get; set; }
public void Dispose() public void Dispose()
{ {
if (!CompletionCommitted) if (!CompletionCommitted)
PreparedGeneration.Dispose(); Physics.CancelCollisionGeneration(
CollisionAdmission,
PreparedGeneration);
} }
public uint LandblockId => Build.Landblock.LandblockId; public uint LandblockId => Build.Landblock.LandblockId;
@ -217,23 +223,34 @@ public sealed class LandblockPhysicsPublisher
?? Array.Empty<BuildingInfo>(); ?? Array.Empty<BuildingInfo>();
RuntimeCollisionAdmission collisionAdmission = RuntimeCollisionAdmission collisionAdmission =
_physics.BeginCollisionAdmission(build.Landblock.LandblockId); _physics.BeginCollisionAdmission(build.Landblock.LandblockId);
var publication = new LandblockPhysicsPublication( PreparedLandblockCollisionGeneration? prepared = null;
_receiptOwner, try
build, {
origin, prepared = _physics.PrepareCollisionGeneration(collisionAdmission);
_physicsDataCache.CellGraph.CurrCell?.Id ?? 0u, var publication = new LandblockPhysicsPublication(
buildings, _receiptOwner,
_physicsEngine.ShadowObjects.CaptureStaticOwnersForLandblock( build,
build.Landblock.LandblockId), origin,
collisionAdmission, _physicsDataCache.CellGraph.CurrCell?.Id ?? 0u,
_physics.PrepareCollisionGeneration(collisionAdmission)); buildings,
publication.SetupObjectIds = build.Collisions is { } collisions _physicsEngine.ShadowObjects.CaptureStaticOwnersForLandblock(
? [.. collisions.SetupIds] build.Landblock.LandblockId),
: datBundle.Setups.Keys.Order().ToArray(); _physics,
publication.PreparedGeneration.SetAssetClosure( collisionAdmission,
publication.GfxObjectIds, prepared);
publication.SetupObjectIds); publication.SetupObjectIds = build.Collisions is { } collisions
return publication; ? [.. collisions.SetupIds]
: datBundle.Setups.Keys.Order().ToArray();
publication.PreparedGeneration.SetAssetClosure(
publication.GfxObjectIds,
publication.SetupObjectIds);
return publication;
}
catch
{
_physics.CancelCollisionGeneration(collisionAdmission, prepared);
throw;
}
} }
/// <summary> /// <summary>
@ -481,15 +498,20 @@ public sealed class LandblockPhysicsPublisher
} }
else if (publication.RefloodOwnerIds is null) else if (publication.RefloodOwnerIds is null)
{ {
publication.RefloodOwnerIds = RuntimeCollisionOwnerCaptureStep capture =
_physics.CaptureCollisionDynamicOwners( _physics.AdvanceCollisionRetainedOwnerCapture(
publication.CollisionAdmission, publication.CollisionAdmission,
publication.PreparedGeneration); publication.PreparedGeneration);
if (capture.Completed)
{
publication.RefloodOwnerIds =
publication.PreparedGeneration.RetainedOwnerIds;
}
} }
else if (publication.RefloodCursor else if (publication.RefloodCursor
< publication.RefloodOwnerIds.Length) < publication.RefloodOwnerIds.Count)
{ {
_physics.RefreshCollisionDynamicOwner( _physics.RefreshCollisionRetainedOwner(
publication.CollisionAdmission, publication.CollisionAdmission,
publication.PreparedGeneration, publication.PreparedGeneration,
publication.RefloodOwnerIds[publication.RefloodCursor]); publication.RefloodOwnerIds[publication.RefloodCursor]);
@ -507,6 +529,25 @@ public sealed class LandblockPhysicsPublisher
LogMissingSceneryBounds(landblock, publication.StagingCache); LogMissingSceneryBounds(landblock, publication.StagingCache);
publication.RefloodCommitted = true; 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 else
{ {
RuntimeCollisionGenerationCommit commit = RuntimeCollisionGenerationCommit commit =
@ -515,9 +556,13 @@ public sealed class LandblockPhysicsPublisher
publication.PreparedGeneration); publication.PreparedGeneration);
if (!commit.Committed) if (!commit.Committed)
{ {
publication.RefloodOwnerIds = commit.DirtyDynamicOwnerIds; _physics.RestartCollisionRetainedOwnerCapture(
publication.CollisionAdmission,
publication.PreparedGeneration);
publication.RefloodOwnerIds = null;
publication.RefloodCursor = 0; publication.RefloodCursor = 0;
publication.RefloodCommitted = false; publication.RefloodCommitted = false;
publication.SealCommitted = false;
_completePublishTicks += Stopwatch.GetTimestamp() - started; _completePublishTicks += Stopwatch.GetTimestamp() - started;
return false; return false;
} }
@ -1011,5 +1056,11 @@ public sealed class LandblockPhysicsPublisher
"The physics publication receipt belongs to another publisher.", "The physics publication receipt belongs to another publisher.",
nameof(publication)); nameof(publication));
} }
if (!publication.CompletionCommitted)
{
ObjectDisposedException.ThrowIf(
publication.PreparedGeneration.IsDisposed,
publication);
}
} }
} }

View file

@ -731,13 +731,15 @@ public sealed class LandblockPresentationPipeline
< transaction.Build.Landblock.Entities.Count < transaction.Build.Landblock.Entities.Count
? 1 ? 1
: transaction.PhysicsPublication : transaction.PhysicsPublication
.RefloodOwnerIds is not null .RefloodOwnerIds is null
&& transaction.PhysicsPublication || transaction.PhysicsPublication
.RefloodCursor .RefloodCursor
< transaction.PhysicsPublication < transaction.PhysicsPublication
.RefloodOwnerIds.Length .RefloodOwnerIds.Count
? 1 || !transaction.PhysicsPublication
: 0; .SealCommitted
? 1
: 0;
if (!TryRun( if (!TryRun(
new StreamingWorkCost( new StreamingWorkCost(
EntityOperations: entityOperations), EntityOperations: entityOperations),

View file

@ -121,31 +121,24 @@ public sealed class PhysicsDataCache
return copy; return copy;
} }
internal PreparedPhysicsDataCacheLandblock PrepareLandblockReplacement( internal LandblockReplacementBuilder CreateLandblockReplacementBuilder(
PhysicsDataCache staging,
uint landblockId, uint landblockId,
ReadOnlySpan<uint> gfxObjectIds, uint[] gfxObjectIds,
ReadOnlySpan<uint> setupIds) uint[] setupIds) => new(
{ this,
uint prefix = landblockId & 0xFFFF0000u; staging,
return new PreparedPhysicsDataCacheLandblock( landblockId,
prefix, gfxObjectIds,
CaptureRequested(_gfxObj, gfxObjectIds), setupIds);
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));
}
internal void CommitLandblockReplacement( internal void CommitLandblockReplacement(
PreparedPhysicsDataCacheLandblock replacement) PreparedPhysicsDataCacheLandblock replacement)
{ {
RemoveCellsForLandblock(replacement.LandblockPrefix); RemoveEntries(_cellStruct, replacement.CellIdsToRemove);
RemoveBuildingsForLandblock(replacement.LandblockPrefix); RemoveEntries(_flatCellStruct, replacement.FlatCellIdsToRemove);
RemoveEntries(_flatEnvCell, replacement.FlatEnvCellIdsToRemove);
RemoveEntries(_buildings, replacement.BuildingIdsToRemove);
CommitEntries(_gfxObj, replacement.GfxObjects, replace: false); CommitEntries(_gfxObj, replacement.GfxObjects, replace: false);
CommitEntries(_visualBounds, replacement.VisualBounds, replace: false); CommitEntries(_visualBounds, replacement.VisualBounds, replace: false);
CommitEntries(_flatGfxObj, replacement.FlatGfxObjects, replace: false); CommitEntries(_flatGfxObj, replacement.FlatGfxObjects, replace: false);
@ -166,34 +159,14 @@ public sealed class PhysicsDataCache
destination.TryAdd(id, value); destination.TryAdd(id, value);
} }
private static KeyValuePair<uint, T>[] CaptureRequested<T>(
ConcurrentDictionary<uint, T> source,
ReadOnlySpan<uint> ids)
{
var result = new List<KeyValuePair<uint, T>>(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<uint, T>(id, value));
}
return result.ToArray();
}
private static KeyValuePair<uint, T>[] CapturePrefix<T>(
ConcurrentDictionary<uint, T> source,
uint prefix) => source
.Where(pair => (pair.Key & 0xFFFF0000u) == prefix)
.OrderBy(static pair => pair.Key)
.ToArray();
private static void CommitEntries<T>( private static void CommitEntries<T>(
ConcurrentDictionary<uint, T> destination, ConcurrentDictionary<uint, T> destination,
KeyValuePair<uint, T>[] entries, IReadOnlyList<KeyValuePair<uint, T>> entries,
bool replace) 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) if (replace)
destination[id] = value; destination[id] = value;
else else
@ -201,6 +174,14 @@ public sealed class PhysicsDataCache
} }
} }
private static void RemoveEntries<T>(
ConcurrentDictionary<uint, T> destination,
IReadOnlyList<uint> ids)
{
for (int index = 0; index < ids.Count; index++)
destination.TryRemove(ids[index], out _);
}
/// <summary> /// <summary>
/// Extract and cache the physics BSP + polygon data from a GfxObj, /// Extract and cache the physics BSP + polygon data from a GfxObj,
/// PLUS always cache a visual AABB from the vertex data regardless of /// PLUS always cache a visual AABB from the vertex data regardless of
@ -1043,19 +1024,268 @@ public sealed class PhysicsDataCache
/// <summary>Test helper, mirrors <see cref="RegisterCellStructForTest"/>.</summary> /// <summary>Test helper, mirrors <see cref="RegisterCellStructForTest"/>.</summary>
public void RegisterBuildingForTest(uint landcellId, BuildingPhysics b) => _buildings[landcellId] = b; 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<KeyValuePair<uint, GfxObjPhysics>> _gfx = new();
private readonly List<KeyValuePair<uint, GfxObjVisualBounds>> _bounds = new();
private readonly List<KeyValuePair<uint, FlatGfxObjCollisionAsset>> _flatGfx = new();
private readonly List<KeyValuePair<uint, SetupPhysics>> _setups = new();
private readonly List<KeyValuePair<uint, FlatSetupCollision>> _flatSetups = new();
private readonly List<KeyValuePair<uint, CellPhysics>> _cells = new();
private readonly List<KeyValuePair<uint, FlatCellStructureCollisionAsset>> _flatCells = new();
private readonly List<KeyValuePair<uint, FlatEnvCellTopology>> _flatEnvCells = new();
private readonly List<KeyValuePair<uint, BuildingPhysics>> _buildings = new();
private readonly HashSet<uint> _cellIds = new();
private readonly HashSet<uint> _flatCellIds = new();
private readonly HashSet<uint> _flatEnvCellIds = new();
private readonly HashSet<uint> _buildingIds = new();
private readonly List<uint> _removeCells = new();
private readonly List<uint> _removeFlatCells = new();
private readonly List<uint> _removeFlatEnvCells = new();
private readonly List<uint> _removeBuildings = new();
private readonly UcgCellGraph.LandblockReplacementBuilder _cellGraph;
private IEnumerator<KeyValuePair<uint, CellPhysics>>? _cellEnumerator;
private IEnumerator<KeyValuePair<uint, FlatCellStructureCollisionAsset>>? _flatCellEnumerator;
private IEnumerator<KeyValuePair<uint, FlatEnvCellTopology>>? _flatEnvEnumerator;
private IEnumerator<KeyValuePair<uint, BuildingPhysics>>? _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<T>(
ConcurrentDictionary<uint, T> source,
uint id,
List<KeyValuePair<uint, T>> destination)
{
if (source.TryGetValue(id, out T? value))
destination.Add(new KeyValuePair<uint, T>(id, value));
}
private static bool CapturePrefixOne<T>(
IEnumerator<KeyValuePair<uint, T>> enumerator,
uint prefix,
List<KeyValuePair<uint, T>> destination,
HashSet<uint> ids)
{
if (!enumerator.MoveNext())
return false;
KeyValuePair<uint, T> pair = enumerator.Current;
if ((pair.Key & 0xFFFF0000u) == prefix)
{
destination.Add(pair);
ids.Add(pair.Key);
}
return true;
}
private static bool CaptureRemovalOne<T>(
IEnumerator<KeyValuePair<uint, T>> enumerator,
uint prefix,
HashSet<uint> retained,
List<uint> 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( internal sealed record PreparedPhysicsDataCacheLandblock(
uint LandblockPrefix, uint LandblockPrefix,
KeyValuePair<uint, GfxObjPhysics>[] GfxObjects, IReadOnlyList<KeyValuePair<uint, GfxObjPhysics>> GfxObjects,
KeyValuePair<uint, GfxObjVisualBounds>[] VisualBounds, IReadOnlyList<KeyValuePair<uint, GfxObjVisualBounds>> VisualBounds,
KeyValuePair<uint, FlatGfxObjCollisionAsset>[] FlatGfxObjects, IReadOnlyList<KeyValuePair<uint, FlatGfxObjCollisionAsset>> FlatGfxObjects,
KeyValuePair<uint, SetupPhysics>[] Setups, IReadOnlyList<KeyValuePair<uint, SetupPhysics>> Setups,
KeyValuePair<uint, FlatSetupCollision>[] FlatSetups, IReadOnlyList<KeyValuePair<uint, FlatSetupCollision>> FlatSetups,
KeyValuePair<uint, CellPhysics>[] Cells, IReadOnlyList<uint> CellIdsToRemove,
KeyValuePair<uint, FlatCellStructureCollisionAsset>[] FlatCells, IReadOnlyList<KeyValuePair<uint, CellPhysics>> Cells,
KeyValuePair<uint, FlatEnvCellTopology>[] FlatEnvCells, IReadOnlyList<uint> FlatCellIdsToRemove,
KeyValuePair<uint, BuildingPhysics>[] Buildings, IReadOnlyList<KeyValuePair<uint, FlatCellStructureCollisionAsset>> FlatCells,
IReadOnlyList<uint> FlatEnvCellIdsToRemove,
IReadOnlyList<KeyValuePair<uint, FlatEnvCellTopology>> FlatEnvCells,
IReadOnlyList<uint> BuildingIdsToRemove,
IReadOnlyList<KeyValuePair<uint, BuildingPhysics>> Buildings,
PreparedCellGraphLandblock CellGraph); PreparedCellGraphLandblock CellGraph);
/// <summary> /// <summary>

View file

@ -182,12 +182,12 @@ public sealed class PhysicsEngine
return staging; return staging;
} }
internal PreparedPhysicsEngineLandblock PrepareLandblockReplacement( internal LandblockReplacementBuilder CreateLandblockReplacementBuilder(
PhysicsEngine staging, PhysicsEngine staging,
uint landblockId, uint landblockId,
ReadOnlySpan<uint> gfxObjectIds, uint[] gfxObjectIds,
ReadOnlySpan<uint> setupIds, uint[] setupIds,
IReadOnlyDictionary<uint, ulong> expectedDynamicVersions) IReadOnlyDictionary<uint, ulong> expectedRetainedVersions)
{ {
ArgumentNullException.ThrowIfNull(staging); ArgumentNullException.ThrowIfNull(staging);
uint canonical = (landblockId & 0xFFFF0000u) | 0xFFFFu; uint canonical = (landblockId & 0xFFFF0000u) | 0xFFFFu;
@ -201,31 +201,25 @@ public sealed class PhysicsEngine
PhysicsDataCache stagingCache = staging.DataCache PhysicsDataCache stagingCache = staging.DataCache
?? throw new InvalidOperationException( ?? throw new InvalidOperationException(
"Staging collision engine has no data cache."); "Staging collision engine has no data cache.");
return new PreparedPhysicsEngineLandblock( return new LandblockReplacementBuilder(
canonical, canonical,
landblock, landblock,
stagingCache.PrepareLandblockReplacement( (DataCache ?? throw new InvalidOperationException(
"Active collision engine has no data cache."))
.CreateLandblockReplacementBuilder(
stagingCache,
canonical, canonical,
gfxObjectIds, gfxObjectIds,
setupIds), setupIds),
ShadowObjects.PrepareLandblockReplacement( ShadowObjects.CreateLandblockReplacementBuilder(
staging.ShadowObjects, staging.ShadowObjects,
canonical, canonical,
expectedDynamicVersions)); expectedRetainedVersions));
} }
internal bool ValidateLandblockReplacement(
PreparedPhysicsEngineLandblock replacement) =>
ShadowObjects.ValidateLandblockReplacement(replacement.Shadows);
internal void CommitLandblockReplacement( internal void CommitLandblockReplacement(
PreparedPhysicsEngineLandblock replacement) PreparedPhysicsEngineLandblock replacement)
{ {
if (!ValidateLandblockReplacement(replacement))
{
throw new InvalidOperationException(
"Collision generation changed after it was sealed.");
}
(DataCache ?? throw new InvalidOperationException( (DataCache ?? throw new InvalidOperationException(
"Active collision engine has no data cache.")) "Active collision engine has no data cache."))
.CommitLandblockReplacement(replacement.DataCache); .CommitLandblockReplacement(replacement.DataCache);
@ -253,6 +247,64 @@ public sealed class PhysicsEngine
internal ShadowObjectRegistry.PreparedLandblockShadowReplacement Shadows { get; } 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();
}
}
/// <summary> /// <summary>
/// Register a landblock with its terrain surface, indoor cells, portal /// Register a landblock with its terrain surface, indoor cells, portal
/// planes, and world-space origin offset. /// planes, and world-space origin offset.

View file

@ -52,6 +52,7 @@ public sealed class ShadowObjectRegistry
/// </summary> /// </summary>
private readonly Dictionary<uint, RegistrationRecord> _entityReg = new(); private readonly Dictionary<uint, RegistrationRecord> _entityReg = new();
private readonly Dictionary<uint, ulong> _ownerVersions = new(); private readonly Dictionary<uint, ulong> _ownerVersions = new();
private ulong _mutationVersion;
internal sealed record RegistrationRecord( internal sealed record RegistrationRecord(
uint SeedCellId, uint SeedCellId,
@ -76,8 +77,88 @@ public sealed class ShadowObjectRegistry
private void BumpOwnerVersion(uint entityId) private void BumpOwnerVersion(uint entityId)
{ {
_ownerVersions[entityId] = checked(GetOwnerVersion(entityId) + 1UL); _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<uint, RegistrationRecord>.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);
/// <summary> /// <summary>
/// The flood's data source (cells, buildings, terrain origins). Wired by /// The flood's data source (cells, buildings, terrain origins). Wired by
/// <see cref="PhysicsEngine"/> when its own <c>DataCache</c> is set. /// <see cref="PhysicsEngine"/> when its own <c>DataCache</c> is set.
@ -624,8 +705,14 @@ public sealed class ShadowObjectRegistry
{ {
// Suspended dynamic objects have no cell rows, but their retained // Suspended dynamic objects have no cell rows, but their retained
// registration must still receive authoritative state changes. // registration must still receive authoritative state changes.
if (_entityReg.TryGetValue(entityId, out var retainedRegistration)) bool retained = _entityReg.TryGetValue(
_entityReg[entityId] = retainedRegistration with { State = newState }; entityId,
out RegistrationRecord? retainedRegistration);
if (retained)
{
_entityReg[entityId] = retainedRegistration! with { State = newState };
BumpOwnerVersion(entityId);
}
if (!_entityToCells.TryGetValue(entityId, out var cellIds)) if (!_entityToCells.TryGetValue(entityId, out var cellIds))
return; // not registered — no-op 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);
} }
/// <summary>Remove an entity from all cells it was registered in.</summary> /// <summary>Remove an entity from all cells it was registered in.</summary>
@ -657,7 +741,7 @@ public sealed class ShadowObjectRegistry
foreach (var cellId in cellIds) foreach (var cellId in cellIds)
{ {
if (_cells.TryGetValue(cellId, out var list)) if (_cells.TryGetValue(cellId, out var list))
list.RemoveAll(e => e.EntityId == entityId); RemoveOwnerRows(list, entityId);
} }
_entityToCells.Remove(entityId); _entityToCells.Remove(entityId);
} }
@ -669,6 +753,17 @@ public sealed class ShadowObjectRegistry
BumpOwnerVersion(entityId); BumpOwnerVersion(entityId);
} }
private static void RemoveOwnerRows(
List<ShadowEntry> entries,
uint entityId)
{
for (int index = entries.Count - 1; index >= 0; index--)
{
if (entries[index].EntityId == entityId)
entries.RemoveAt(index);
}
}
/// <summary> /// <summary>
/// Logically tear down every static object owned by a landblock, including /// Logically tear down every static object owned by a landblock, including
/// shadow rows flooded into adjacent landblocks. Dynamic/server-live owners /// shadow rows flooded into adjacent landblocks. Dynamic/server-live owners
@ -824,6 +919,11 @@ public sealed class ShadowObjectRegistry
/// <summary>Suspended logical registrations awaiting spatial re-entry.</summary> /// <summary>Suspended logical registrations awaiting spatial re-entry.</summary>
public int SuspendedRegistrationCount => _suspendedEntities.Count; public int SuspendedRegistrationCount => _suspendedEntities.Count;
public bool HasOwnerRowsInLandblock(uint ownerId, uint landblockId) =>
_entityToCells.TryGetValue(ownerId, out List<uint>? cells)
&& cells.Exists(cell =>
(cell & 0xFFFF0000u) == (landblockId & 0xFFFF0000u));
/// <summary> /// <summary>
/// Copies the committed registry into an off-side collision generation. /// Copies the committed registry into an off-side collision generation.
/// All mutable lists and sets are cloned; immutable registration and shape /// 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) foreach ((uint ownerId, ulong version) in source._ownerVersions)
_ownerVersions[ownerId] = version; _ownerVersions[ownerId] = version;
} _mutationVersion = source._mutationVersion;
internal uint[] CaptureDynamicRefloodOwnersForLandblock(
uint landblockId)
{
uint[] owners = CaptureRefloodOwnersForLandblock(landblockId);
return owners.Where(ownerId =>
_entityReg.TryGetValue(ownerId, out RegistrationRecord? record)
&& !record.IsStatic)
.ToArray();
} }
/// <summary> /// <summary>
@ -877,7 +968,7 @@ public sealed class ShadowObjectRegistry
/// it against the staging generation's complete cell graph. The returned /// it against the staging generation's complete cell graph. The returned
/// source version is the commit-time freshness token. /// source version is the commit-time freshness token.
/// </summary> /// </summary>
internal bool RefreshDynamicOwnerFrom( internal bool RefreshRetainedOwnerFrom(
ShadowObjectRegistry source, ShadowObjectRegistry source,
uint entityId, uint entityId,
uint landblockId, uint landblockId,
@ -889,8 +980,10 @@ public sealed class ShadowObjectRegistry
if (!source._entityReg.TryGetValue( if (!source._entityReg.TryGetValue(
entityId, entityId,
out RegistrationRecord? registration) out RegistrationRecord? registration)
|| registration.IsStatic
|| source._suspendedEntities.Contains(entityId) || source._suspendedEntities.Contains(entityId)
|| (registration.IsStatic
&& (registration.SeedCellId & 0xFFFF0000u)
== (landblockId & 0xFFFF0000u))
|| !source.OwnerTouchesLandblock(entityId, landblockId)) || !source.OwnerTouchesLandblock(entityId, landblockId))
{ {
return false; return false;
@ -912,7 +1005,7 @@ public sealed class ShadowObjectRegistry
0f, 0f,
landblockId, landblockId,
registration.SeedCellId, registration.SeedCellId,
isStatic: false); isStatic: registration.IsStatic);
} }
else else
{ {
@ -931,92 +1024,42 @@ public sealed class ShadowObjectRegistry
registration.State, registration.State,
registration.Flags, registration.Flags,
registration.SeedCellId, registration.SeedCellId,
isStatic: false); isStatic: registration.IsStatic);
}
if (source._withdrawnPrefixesByOwner.TryGetValue(
entityId,
out HashSet<uint>? sourceWithdrawn))
{
var retainedWithdrawn = new HashSet<uint>(sourceWithdrawn);
uint prefix = landblockId & 0xFFFF0000u;
if (_entityToCells.TryGetValue(entityId, out List<uint>? cells)
&& cells.Exists(cell => (cell & 0xFFFF0000u) == prefix))
{
retainedWithdrawn.Remove(prefix);
}
if (retainedWithdrawn.Count != 0)
_withdrawnPrefixesByOwner[entityId] = retainedWithdrawn;
} }
return true; return true;
} }
internal uint[] FindDirtyDynamicOwners( internal LandblockReplacementBuilder CreateLandblockReplacementBuilder(
uint landblockId,
IReadOnlyDictionary<uint, ulong> expectedVersions)
{
var dirty = new HashSet<uint>(
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(
ShadowObjectRegistry staging, ShadowObjectRegistry staging,
uint landblockId, uint landblockId,
IReadOnlyDictionary<uint, ulong> expectedDynamicVersions) IReadOnlyDictionary<uint, ulong> expectedRetainedVersions) => new(
{ this,
ArgumentNullException.ThrowIfNull(staging); staging,
uint[] dirty = FindDirtyDynamicOwners(
landblockId, landblockId,
expectedDynamicVersions); expectedRetainedVersions);
if (dirty.Length != 0)
{
throw new InvalidOperationException(
"Dynamic shadow owners changed before collision generation sealing.");
}
var owners = new HashSet<uint>(CaptureStaticOwnersForLandblock(landblockId));
owners.UnionWith(staging.CaptureStaticOwnersForLandblock(landblockId));
owners.UnionWith(expectedDynamicVersions.Keys);
uint[] ownerIds = owners.ToArray();
Array.Sort(ownerIds);
var states = new List<PreparedShadowOwnerState>(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());
}
internal void CommitLandblockReplacement( internal void CommitLandblockReplacement(
PreparedLandblockShadowReplacement replacement) PreparedLandblockShadowReplacement replacement)
{ {
if (!ValidateLandblockReplacement(replacement)) for (int index = 0; index < replacement.OwnerIds.Count; index++)
{ Deregister(replacement.OwnerIds[index]);
throw new InvalidOperationException( for (int index = 0; index < replacement.OwnerStates.Count; index++)
"Dynamic shadow owners changed before collision generation commit."); InstallOwnerState(replacement.OwnerStates[index]);
}
foreach (uint ownerId in replacement.OwnerIds)
Deregister(ownerId);
foreach (PreparedShadowOwnerState state in replacement.OwnerStates)
InstallOwnerState(state);
} }
private bool OwnerTouchesLandblock(uint entityId, uint landblockId) private bool OwnerTouchesLandblock(uint entityId, uint landblockId)
@ -1071,10 +1114,10 @@ public sealed class ShadowObjectRegistry
entityId, entityId,
registration, registration,
shapes, shapes,
cells?.ToArray() ?? Array.Empty<uint>(), cells is null ? null : new List<uint>(cells),
rows.ToArray(), rows,
_suspendedEntities.Contains(entityId), _suspendedEntities.Contains(entityId),
withdrawn?.ToArray() ?? Array.Empty<uint>()); withdrawn is null ? null : new HashSet<uint>(withdrawn));
return true; return true;
} }
@ -1085,49 +1128,194 @@ public sealed class ShadowObjectRegistry
_entityShapes[state.EntityId] = state.Shapes; _entityShapes[state.EntityId] = state.Shapes;
if (state.Suspended) if (state.Suspended)
_suspendedEntities.Add(state.EntityId); _suspendedEntities.Add(state.EntityId);
if (state.WithdrawnPrefixes.Length != 0) if (state.WithdrawnPrefixes is not null)
{ {
_withdrawnPrefixesByOwner[state.EntityId] = _withdrawnPrefixesByOwner[state.EntityId] = state.WithdrawnPrefixes;
new HashSet<uint>(state.WithdrawnPrefixes);
} }
if (state.CellIds.Length != 0) if (state.CellIds is not null)
_entityToCells[state.EntityId] = new List<uint>(state.CellIds); _entityToCells[state.EntityId] = state.CellIds;
foreach (PreparedShadowCellRows row in state.Rows) for (int rowIndex = 0; rowIndex < state.Rows.Count; rowIndex++)
{ {
foreach (ShadowEntry entry in row.Entries) PreparedShadowCellRows row = state.Rows[rowIndex];
AddEntryToCell(entry, row.CellId); for (int entryIndex = 0; entryIndex < row.Entries.Length; entryIndex++)
AddEntryToCell(row.Entries[entryIndex], row.CellId);
} }
BumpOwnerVersion(state.EntityId); 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<uint> _owners = new();
private readonly List<uint> _ownerIds = new();
private readonly List<PreparedShadowOwnerState> _states = new();
private IEnumerator<KeyValuePair<uint, ulong>>? _expectedEnumerator;
private Dictionary<uint, RegistrationRecord>.Enumerator _registrationEnumerator;
private HashSet<uint>.Enumerator _ownerEnumerator;
private int _phase;
internal LandblockReplacementBuilder(
ShadowObjectRegistry active,
ShadowObjectRegistry staging,
uint landblockId,
IReadOnlyDictionary<uint, ulong> 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 sealed class PreparedLandblockShadowReplacement
{ {
internal PreparedLandblockShadowReplacement( internal PreparedLandblockShadowReplacement(
uint landblockPrefix, uint landblockPrefix,
uint[] ownerIds, IReadOnlyList<uint> ownerIds,
PreparedShadowOwnerState[] ownerStates, IReadOnlyList<PreparedShadowOwnerState> ownerStates)
Dictionary<uint, ulong> dynamicVersions)
{ {
LandblockPrefix = landblockPrefix; LandblockPrefix = landblockPrefix;
OwnerIds = ownerIds; OwnerIds = ownerIds;
OwnerStates = ownerStates; OwnerStates = ownerStates;
DynamicVersions = dynamicVersions;
} }
internal uint LandblockPrefix { get; } internal uint LandblockPrefix { get; }
internal uint[] OwnerIds { get; } internal IReadOnlyList<uint> OwnerIds { get; }
internal PreparedShadowOwnerState[] OwnerStates { get; } internal IReadOnlyList<PreparedShadowOwnerState> OwnerStates { get; }
internal Dictionary<uint, ulong> DynamicVersions { get; }
} }
internal sealed record PreparedShadowOwnerState( internal sealed record PreparedShadowOwnerState(
uint EntityId, uint EntityId,
RegistrationRecord Registration, RegistrationRecord Registration,
IReadOnlyList<ShadowShape>? Shapes, IReadOnlyList<ShadowShape>? Shapes,
uint[] CellIds, List<uint>? CellIds,
PreparedShadowCellRows[] Rows, IReadOnlyList<PreparedShadowCellRows> Rows,
bool Suspended, bool Suspended,
uint[] WithdrawnPrefixes); HashSet<uint>? WithdrawnPrefixes);
internal sealed record PreparedShadowCellRows( internal sealed record PreparedShadowCellRows(
uint CellId, uint CellId,
@ -1146,6 +1334,7 @@ public sealed class ShadowObjectRegistry
_entityShapes.Clear(); _entityShapes.Clear();
_entityReg.Clear(); _entityReg.Clear();
_ownerVersions.Clear(); _ownerVersions.Clear();
_mutationVersion = 0UL;
_fallback = null; _fallback = null;
} }

View file

@ -19,7 +19,7 @@ namespace AcDream.Core.World.Cells;
public sealed class CellGraph public sealed class CellGraph
{ {
private readonly ConcurrentDictionary<uint, EnvCell> _envCells = new(); private readonly ConcurrentDictionary<uint, EnvCell> _envCells = new();
private readonly ConcurrentDictionary<uint, (TerrainSurface Terrain, Vector3 Origin)> _terrain = new(); private readonly ConcurrentDictionary<uint, CellGraphTerrain> _terrain = new();
/// <summary>The player's current cell — the render/lighting root. Written ONLY at the /// <summary>The player's current cell — the render/lighting root. Written ONLY at the
/// player chokepoint <see cref="AcDream.Core.Physics.PhysicsEngine.UpdatePlayerCurrCell"/> /// player chokepoint <see cref="AcDream.Core.Physics.PhysicsEngine.UpdatePlayerCurrCell"/>
@ -34,7 +34,8 @@ public sealed class CellGraph
/// <param name="landblockPrefix">Any id in the cell's landblock; masked to (id &amp; 0xFFFF0000).</param> /// <param name="landblockPrefix">Any id in the cell's landblock; masked to (id &amp; 0xFFFF0000).</param>
public void RegisterTerrain(uint landblockPrefix, TerrainSurface terrain, Vector3 worldOrigin) public void RegisterTerrain(uint landblockPrefix, TerrainSurface terrain, Vector3 worldOrigin)
=> _terrain[landblockPrefix & 0xFFFF0000u] = (terrain, worldOrigin); => _terrain[landblockPrefix & 0xFFFF0000u] =
new CellGraphTerrain(terrain, worldOrigin);
/// <summary> /// <summary>
/// World origin (SW corner) of the landblock containing <paramref name="id"/>, /// World origin (SW corner) of the landblock containing <paramref name="id"/>,
@ -137,46 +138,36 @@ public sealed class CellGraph
var copy = new CellGraph { CurrCell = CurrCell }; var copy = new CellGraph { CurrCell = CurrCell };
foreach ((uint id, EnvCell cell) in _envCells) foreach ((uint id, EnvCell cell) in _envCells)
copy._envCells.TryAdd(id, cell); copy._envCells.TryAdd(id, cell);
foreach ((uint id, (TerrainSurface Terrain, Vector3 Origin) terrain) in foreach ((uint id, CellGraphTerrain terrain) in _terrain)
_terrain)
{ {
copy._terrain.TryAdd(id, terrain); copy._terrain.TryAdd(id, terrain);
} }
return copy; return copy;
} }
internal PreparedCellGraphLandblock PrepareLandblockReplacement( internal LandblockReplacementBuilder CreateLandblockReplacementBuilder(
uint landblockId) CellGraph staging,
{ uint landblockId) => new(this, staging, landblockId);
uint prefix = landblockId & 0xFFFF0000u;
KeyValuePair<uint, EnvCell>[] 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 void CommitLandblockReplacement( internal void CommitLandblockReplacement(
PreparedCellGraphLandblock replacement) PreparedCellGraphLandblock replacement)
{ {
uint currentCellId = CurrCell?.Id ?? 0u; 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) if (replacement.HasTerrain)
{ {
_terrain[replacement.LandblockPrefix] = ( _terrain[replacement.LandblockPrefix] = replacement.Terrain!;
replacement.Terrain!,
replacement.Origin);
} }
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; _envCells[id] = cell;
}
uint desiredCurrentCellId = uint desiredCurrentCellId =
(currentCellId & 0xFFFF0000u) == replacement.LandblockPrefix (currentCellId & 0xFFFF0000u) == replacement.LandblockPrefix
@ -188,13 +179,101 @@ public sealed class CellGraph
: 0u; : 0u;
if (desiredCurrentCellId != 0u) if (desiredCurrentCellId != 0u)
CurrCell = GetVisible(desiredCurrentCellId); 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<KeyValuePair<uint, EnvCell>> _envCells = new();
private readonly HashSet<uint> _stagingIds = new();
private readonly List<uint> _removeIds = new();
private IEnumerator<KeyValuePair<uint, EnvCell>>? _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<uint, EnvCell> 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( internal sealed record PreparedCellGraphLandblock(
uint LandblockPrefix, uint LandblockPrefix,
KeyValuePair<uint, EnvCell>[] EnvCells, IReadOnlyList<uint> EnvCellIdsToRemove,
IReadOnlyList<KeyValuePair<uint, EnvCell>> EnvCells,
bool HasTerrain, bool HasTerrain,
TerrainSurface? Terrain, CellGraphTerrain? Terrain,
Vector3 Origin,
uint CurrentCellId); uint CurrentCellId);
internal sealed record CellGraphTerrain(
TerrainSurface Terrain,
Vector3 Origin);

View file

@ -18,6 +18,77 @@ internal interface IHeadlessCollisionNeighborhood
bool IsReady(uint fullCellId); bool IsReady(uint fullCellId);
} }
internal static class HeadlessCollisionGenerationTransaction
{
internal static RuntimeCollisionGenerationCommit Execute(
RuntimePhysicsState physics,
uint landblockId,
Action<RuntimeCollisionAdmission>? afterAdmission,
Action<RuntimeCollisionAdmission,
PreparedLandblockCollisionGeneration> 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);
}
}
}
/// <summary> /// <summary>
/// Per-session mutable collision publication over process-shared immutable /// Per-session mutable collision publication over process-shared immutable
/// DAT and pak inputs. Every session retains its own engine, data cache, /// DAT and pak inputs. Every session retains its own engine, data cache,
@ -172,81 +243,58 @@ internal sealed class HeadlessCollisionNeighborhood
_content.PreparedCollision, _content.PreparedCollision,
landblock); landblock);
RuntimePhysicsState physics = RuntimePhysicsState physics = _runtime.EntityObjects.Physics;
_runtime.EntityObjects.Physics; _ = HeadlessCollisionGenerationTransaction.Execute(
RuntimeCollisionAdmission admission = physics,
physics.BeginCollisionAdmission(landblockId); landblockId,
using PreparedLandblockCollisionGeneration prepared = afterAdmission: null,
physics.PrepareCollisionGeneration(admission); (admission, prepared) =>
prepared.SetAssetClosure( {
[.. collisions.GfxObjIds], prepared.SetAssetClosure(
[.. collisions.SetupIds]); [.. collisions.GfxObjIds],
PhysicsDataCache cache = prepared.DataCache; [.. collisions.SetupIds]);
TerrainSurface terrain = PhysicsDataCache cache = prepared.DataCache;
LandblockPhysicsContentBuilder.BuildTerrainSurface( TerrainSurface terrain =
landblock, LandblockPhysicsContentBuilder.BuildTerrainSurface(
_content.HeightTable.AsSpan()); landblock,
var cellSurfaces = new List<CellSurface>(); _content.HeightTable.AsSpan());
var portalPlanes = new List<PortalPlane>(); var cellSurfaces = new List<CellSurface>();
LandblockPhysicsContentBuilder.PublishPreparedCells( var portalPlanes = new List<PortalPlane>();
cache, LandblockPhysicsContentBuilder.PublishPreparedCells(
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,
cache, cache,
landblock, landblock,
collisions, collisions,
origin,
cellSurfaces,
portalPlanes);
LandblockPhysicsContentBuilder.CacheBuildings(
cache,
landblock,
terrain,
origin); origin);
foreach (uint ownerId in physics.CaptureCollisionDynamicOwners( LandblockPhysicsContentBuilder.CachePreparedObjects(
admission, cache,
prepared)) collisions);
{ physics.StageCollisionAssets(
physics.RefreshCollisionDynamicOwner(
admission, admission,
prepared, prepared,
ownerId); new RuntimeLandblockCollisionAssets(
} landblockId,
RuntimeCollisionGenerationCommit commit = terrain,
physics.CommitCollisionGeneration(admission, prepared); cellSurfaces,
if (!commit.Committed) portalPlanes,
{ origin.X,
throw new InvalidOperationException( origin.Y,
"Headless collision generation changed during synchronous publication."); currentCellId));
} _ = LandblockPhysicsContentBuilder
_resident.Add(CanonicalLandblock(landblockId)); .PublishStaticCollision(
} prepared.Engine,
catch cache,
{ landblock,
_ = physics.WithdrawCollision(landblockId); collisions,
throw; origin);
} });
_resident.Add(CanonicalLandblock(landblockId));
} }
private void RetireAll() private void RetireAll()

View file

@ -68,7 +68,7 @@ public readonly record struct RuntimeCollisionAcknowledgement(
public readonly record struct RuntimeCollisionGenerationCommit( public readonly record struct RuntimeCollisionGenerationCommit(
RuntimeCollisionAcknowledgement Acknowledgement, RuntimeCollisionAcknowledgement Acknowledgement,
uint[] DirtyDynamicOwnerIds) uint[] DirtyRetainedOwnerIds)
{ {
public bool Committed => Acknowledgement.Ready; public bool Committed => Acknowledgement.Ready;
} }
@ -87,7 +87,11 @@ internal sealed class PreparedLandblockCollisionGeneration : IDisposable
{ {
private readonly RuntimePhysicsState _owner; private readonly RuntimePhysicsState _owner;
private readonly RuntimeCollisionAdmission _admission; private readonly RuntimeCollisionAdmission _admission;
private readonly Dictionary<uint, ulong> _dynamicOwnerVersions = new(); private readonly Dictionary<uint, ulong> _retainedOwnerVersions = new();
private readonly List<uint> _retainedOwnerIds = new();
private ShadowObjectRegistry.RetainedRefloodOwnerScan? _retainedOwnerScan;
private PhysicsEngine.LandblockReplacementBuilder? _sealBuilder;
private PhysicsEngine.PreparedPhysicsEngineLandblock? _sealedReplacement;
private bool _disposed; private bool _disposed;
internal PreparedLandblockCollisionGeneration( internal PreparedLandblockCollisionGeneration(
@ -106,9 +110,13 @@ internal sealed class PreparedLandblockCollisionGeneration : IDisposable
internal PhysicsEngine Engine { get; } internal PhysicsEngine Engine { get; }
internal uint[] GfxObjectIds { get; private set; } = Array.Empty<uint>(); internal uint[] GfxObjectIds { get; private set; } = Array.Empty<uint>();
internal uint[] SetupIds { get; private set; } = Array.Empty<uint>(); internal uint[] SetupIds { get; private set; } = Array.Empty<uint>();
internal IReadOnlyDictionary<uint, ulong> DynamicOwnerVersions => internal IReadOnlyDictionary<uint, ulong> RetainedOwnerVersions =>
_dynamicOwnerVersions; _retainedOwnerVersions;
internal bool IsDisposed => _disposed; 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( internal bool Matches(
RuntimePhysicsState owner, RuntimePhysicsState owner,
@ -123,26 +131,155 @@ internal sealed class PreparedLandblockCollisionGeneration : IDisposable
SetupIds = setupIds ?? throw new ArgumentNullException(nameof(setupIds)); SetupIds = setupIds ?? throw new ArgumentNullException(nameof(setupIds));
} }
internal void RefreshDynamicOwner(uint ownerId) internal void RefreshRetainedOwner(uint ownerId)
{ {
EnsureUsable(); EnsureUsable();
bool retained = Engine.ShadowObjects.RefreshDynamicOwnerFrom( bool retained = Engine.ShadowObjects.RefreshRetainedOwnerFrom(
_owner.Engine.ShadowObjects, _owner.Engine.ShadowObjects,
ownerId, ownerId,
_admission.LandblockId, _admission.LandblockId,
out ulong version); out ulong version);
if (retained) if (retained)
_dynamicOwnerVersions[ownerId] = version; _retainedOwnerVersions[ownerId] = version;
else else
_dynamicOwnerVersions.Remove(ownerId); _retainedOwnerVersions.Remove(ownerId);
} }
internal uint[] FindDirtyDynamicOwners() internal RuntimeCollisionOwnerCaptureStep AdvanceRetainedOwnerCapture()
{ {
EnsureUsable(); 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<uint> 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, _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() internal void MarkCommitted()
@ -156,7 +293,14 @@ internal sealed class PreparedLandblockCollisionGeneration : IDisposable
if (_disposed) if (_disposed)
return; return;
Engine.Clear(); Engine.Clear();
_dynamicOwnerVersions.Clear(); _retainedOwnerScan?.Dispose();
_retainedOwnerScan = null;
_retainedOwnerIds.Clear();
_retainedOwnerVersions.Clear();
_sealBuilder?.Dispose();
_sealBuilder = null;
_sealedReplacement = null;
SealedShadowMutationVersion = 0UL;
_disposed = true; _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);
/// <summary> /// <summary>
/// Presentation-free mutable physics world for one Runtime/session owner. /// Presentation-free mutable physics world for one Runtime/session owner.
/// Immutable prepared collision inputs may be supplied by a graphical or /// Immutable prepared collision inputs may be supplied by a graphical or
@ -1001,6 +1156,43 @@ public sealed class RuntimePhysicsState : IDisposable
stagingEngine); stagingEngine);
} }
/// <summary>
/// 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.
/// </summary>
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( internal void StageCollisionAssets(
RuntimeCollisionAdmission admission, RuntimeCollisionAdmission admission,
PreparedLandblockCollisionGeneration prepared, PreparedLandblockCollisionGeneration prepared,
@ -1043,18 +1235,17 @@ public sealed class RuntimePhysicsState : IDisposable
admission.AssetsPrepared = true; admission.AssetsPrepared = true;
} }
internal uint[] CaptureCollisionDynamicOwners( internal RuntimeCollisionOwnerCaptureStep AdvanceCollisionRetainedOwnerCapture(
RuntimeCollisionAdmission admission, RuntimeCollisionAdmission admission,
PreparedLandblockCollisionGeneration prepared) PreparedLandblockCollisionGeneration prepared)
{ {
ValidateAdmission(admission); ValidateAdmission(admission);
EnsureCollisionMutationThread(); EnsureCollisionMutationThread();
ValidatePreparedGeneration(admission, prepared); ValidatePreparedGeneration(admission, prepared);
return Engine.ShadowObjects.CaptureDynamicRefloodOwnersForLandblock( return prepared.AdvanceRetainedOwnerCapture();
admission.LandblockId);
} }
internal void RefreshCollisionDynamicOwner( internal void RefreshCollisionRetainedOwner(
RuntimeCollisionAdmission admission, RuntimeCollisionAdmission admission,
PreparedLandblockCollisionGeneration prepared, PreparedLandblockCollisionGeneration prepared,
uint ownerId) uint ownerId)
@ -1062,7 +1253,32 @@ public sealed class RuntimePhysicsState : IDisposable
ValidateAdmission(admission); ValidateAdmission(admission);
EnsureCollisionMutationThread(); EnsureCollisionMutationThread();
ValidatePreparedGeneration(admission, prepared); 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( internal RuntimeCollisionGenerationCommit CommitCollisionGeneration(
@ -1083,38 +1299,25 @@ public sealed class RuntimePhysicsState : IDisposable
"Collision generation has already completed."); "Collision generation has already completed.");
} }
uint[] dirtyOwners = prepared.FindDirtyDynamicOwners(); if (!prepared.IsSealed)
if (dirtyOwners.Length != 0)
{ {
throw new InvalidOperationException(
"Collision generation cannot activate before sealing.");
}
if (Engine.ShadowObjects.MutationVersion
!= prepared.SealedShadowMutationVersion)
{
prepared.ResetRetainedOwnerCapture();
return new RuntimeCollisionGenerationCommit( return new RuntimeCollisionGenerationCommit(
new RuntimeCollisionAcknowledgement( new RuntimeCollisionAcknowledgement(
admission.LandblockId, admission.LandblockId,
admission.Generation, admission.Generation,
Engine.IsLandblockTerrainResident(admission.LandblockId), Engine.IsLandblockTerrainResident(admission.LandblockId),
Ready: false), Ready: false),
dirtyOwners); Array.Empty<uint>());
} }
PhysicsEngine.PreparedPhysicsEngineLandblock replacement = Engine.CommitLandblockReplacement(prepared.TakeSealedReplacement());
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);
admission.Completed = true; admission.Completed = true;
_collisionAdmissions.Remove(admission.LandblockId); _collisionAdmissions.Remove(admission.LandblockId);
prepared.MarkCommitted(); prepared.MarkCommitted();

View file

@ -976,6 +976,43 @@ public sealed class LandblockPhysicsPublisherTests
new Dictionary<uint, GfxObj>()); new Dictionary<uint, GfxObj>());
} }
[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( private static LandblockCollisionBuild FlatCellClosure(
PhysicsDatBundle bundle, PhysicsDatBundle bundle,
uint envCellId) uint envCellId)

View file

@ -12,6 +12,7 @@ using AcDream.Headless.Platform;
using AcDream.Runtime; using AcDream.Runtime;
using AcDream.Runtime.Entities; using AcDream.Runtime.Entities;
using AcDream.Runtime.Gameplay; using AcDream.Runtime.Gameplay;
using AcDream.Runtime.Physics;
using AcDream.Runtime.Session; using AcDream.Runtime.Session;
using AcDream.Runtime.World; using AcDream.Runtime.World;
@ -373,6 +374,72 @@ public sealed class HeadlessSessionHostTests
Assert.Equal(2, collision.CenterCount); 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<FixtureCollisionPublicationException>(() =>
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<FixtureCollisionPublicationException>(() =>
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( private static HeadlessSessionDescriptor Descriptor(
HeadlessCredentialProviderKind provider = HeadlessCredentialProviderKind provider =
HeadlessCredentialProviderKind.Environment, HeadlessCredentialProviderKind.Environment,
@ -422,6 +489,25 @@ public sealed class HeadlessSessionHostTests
runtime.MovementOwner.Controller = controller; 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<CellSurface>(),
Array.Empty<PortalPlane>(),
0f,
0f,
0u);
}
private sealed class FixtureCollisionPublicationException : Exception;
private static void AddFlatLandblock(PhysicsEngine engine) private static void AddFlatLandblock(PhysicsEngine engine)
{ {
var heights = new byte[81]; var heights = new byte[81];

View file

@ -322,7 +322,7 @@ public sealed class RuntimePhysicsStateTests
prepared, prepared,
CollisionAssets(0xA9B4FFFFu)); CollisionAssets(0xA9B4FFFFu));
RuntimeCollisionGenerationCommit commit = RuntimeCollisionGenerationCommit commit =
first.Physics.CommitCollisionGeneration(newer, prepared); CommitPrepared(first.Physics, newer, prepared);
RuntimeCollisionAcknowledgement completed = commit.Acknowledgement; RuntimeCollisionAcknowledgement completed = commit.Acknowledgement;
Assert.True(commit.Committed); Assert.True(commit.Committed);
@ -332,7 +332,7 @@ public sealed class RuntimePhysicsStateTests
0, 0,
first.Physics.CaptureOwnership().CollisionAdmissionCount); first.Physics.CaptureOwnership().CollisionAdmissionCount);
Assert.Throws<InvalidOperationException>(() => Assert.Throws<InvalidOperationException>(() =>
first.Physics.CommitCollisionGeneration(newer, prepared)); CommitPrepared(first.Physics, newer, prepared));
RuntimeCollisionAcknowledgement withdrawn = RuntimeCollisionAcknowledgement withdrawn =
first.Physics.WithdrawCollision(0xA9B4FFFFu); first.Physics.WithdrawCollision(0xA9B4FFFFu);
@ -355,7 +355,7 @@ public sealed class RuntimePhysicsStateTests
firstAdmission, firstAdmission,
first, first,
CollisionAssets(0xA9B4FFFFu, terrainHeight: 10f)); CollisionAssets(0xA9B4FFFFu, terrainHeight: 10f));
Assert.True(physics.CommitCollisionGeneration( Assert.True(CommitPrepared(physics,
firstAdmission, firstAdmission,
first).Committed); first).Committed);
} }
@ -375,7 +375,7 @@ public sealed class RuntimePhysicsStateTests
Assert.Equal(0, notifications); Assert.Equal(0, notifications);
RuntimeCollisionGenerationCommit committed = RuntimeCollisionGenerationCommit committed =
physics.CommitCollisionGeneration( CommitPrepared(physics,
replacementAdmission, replacementAdmission,
replacement); replacement);
@ -401,7 +401,11 @@ public sealed class RuntimePhysicsStateTests
RuntimeCollisionAdmission current = RuntimeCollisionAdmission current =
physics.BeginCollisionAdmission(0xA9B4FFFFu); physics.BeginCollisionAdmission(0xA9B4FFFFu);
Assert.Throws<InvalidOperationException>(() => Assert.Throws<InvalidOperationException>(() =>
physics.CommitCollisionGeneration(stale, preparedStale)); CommitPrepared(physics, stale, preparedStale));
physics.CancelCollisionGeneration(stale, preparedStale);
Assert.Equal(
1,
physics.CaptureOwnership().CollisionAdmissionCount);
Assert.False(physics.Engine.IsLandblockTerrainResident(0xA9B4FFFFu)); Assert.False(physics.Engine.IsLandblockTerrainResident(0xA9B4FFFFu));
using PreparedLandblockCollisionGeneration preparedCurrent = using PreparedLandblockCollisionGeneration preparedCurrent =
@ -410,14 +414,14 @@ public sealed class RuntimePhysicsStateTests
current, current,
preparedCurrent, preparedCurrent,
CollisionAssets(0xA9B4FFFFu, terrainHeight: 20f)); CollisionAssets(0xA9B4FFFFu, terrainHeight: 20f));
Assert.True(physics.CommitCollisionGeneration( Assert.True(CommitPrepared(physics,
current, current,
preparedCurrent).Committed); preparedCurrent).Committed);
Assert.Equal(20f, physics.Engine.SampleTerrainZ(1f, 1f)); Assert.Equal(20f, physics.Engine.SampleTerrainZ(1f, 1f));
} }
[Fact] [Fact]
public void MovingDynamicOwnerDuringStagingMustRefreshBeforeCommit() public void WithdrawnOwnerStateChangeRejectsSealUntilRefreshed()
{ {
using var lifetime = new RuntimeEntityObjectLifetime(); using var lifetime = new RuntimeEntityObjectLifetime();
RuntimePhysicsState physics = lifetime.Physics; RuntimePhysicsState physics = lifetime.Physics;
@ -430,7 +434,7 @@ public sealed class RuntimePhysicsStateTests
initialAdmission, initialAdmission,
initial, initial,
CollisionAssets(0x0101FFFFu, terrainHeight: 5f)); CollisionAssets(0x0101FFFFu, terrainHeight: 5f));
Assert.True(physics.CommitCollisionGeneration( Assert.True(CommitPrepared(physics,
initialAdmission, initialAdmission,
initial).Committed); initial).Committed);
} }
@ -445,6 +449,9 @@ public sealed class RuntimePhysicsStateTests
0x0101FFFFu, 0x0101FFFFu,
seedCellId: 0x01010001u, seedCellId: 0x01010001u,
isStatic: false); isStatic: false);
physics.Engine.ShadowObjects.RemoveLandblock(0x0101FFFFu);
Assert.Equal(0, physics.Engine.ShadowObjects.TotalRegistered);
Assert.Equal(1, physics.Engine.ShadowObjects.RetainedRegistrationCount);
RuntimeCollisionAdmission admission = RuntimeCollisionAdmission admission =
physics.BeginCollisionAdmission(0x0101FFFFu); physics.BeginCollisionAdmission(0x0101FFFFu);
@ -454,33 +461,125 @@ public sealed class RuntimePhysicsStateTests
admission, admission,
prepared, prepared,
CollisionAssets(0x0101FFFFu, terrainHeight: 15f)); CollisionAssets(0x0101FFFFu, terrainHeight: 15f));
uint owner = Assert.Single(physics.CaptureCollisionDynamicOwners( uint owner = Assert.Single(SealPrepared(physics, admission, prepared));
admission, physics.Engine.ShadowObjects.UpdatePhysicsState(owner, 0x14u);
prepared));
physics.RefreshCollisionDynamicOwner(admission, prepared, owner);
physics.Engine.ShadowObjects.UpdatePosition(
owner,
new Vector3(30f, 10f, 0f),
Quaternion.Identity,
0f,
0f,
0x0101FFFFu,
seedCellId: 0x01010009u);
RuntimeCollisionGenerationCommit rejected = RuntimeCollisionGenerationCommit rejected =
physics.CommitCollisionGeneration(admission, prepared); physics.CommitCollisionGeneration(admission, prepared);
Assert.False(rejected.Committed); Assert.False(rejected.Committed);
Assert.Equal(owner, Assert.Single(rejected.DirtyDynamicOwnerIds));
Assert.Equal(5f, physics.Engine.SampleTerrainZ(1f, 1f)); 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( Assert.True(physics.CommitCollisionGeneration(
admission, admission,
prepared).Committed); prepared).Committed);
Assert.Equal(15f, physics.Engine.SampleTerrainZ(1f, 1f)); Assert.Equal(15f, physics.Engine.SampleTerrainZ(1f, 1f));
Assert.Contains( ShadowEntry restored = Assert.Single(
physics.Engine.ShadowObjects.GetObjectsInCell(0x01010009u), physics.Engine.ShadowObjects.AllEntriesForDebug());
entry => entry.EntityId == owner); 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] [Fact]
@ -496,7 +595,7 @@ public sealed class RuntimePhysicsStateTests
admission, admission,
prepared, prepared,
CollisionAssets(0x0101FFFFu, terrainHeight: 12f)); CollisionAssets(0x0101FFFFu, terrainHeight: 12f));
Assert.Empty(physics.CaptureCollisionDynamicOwners(admission, prepared)); Assert.Empty(SealPrepared(physics, admission, prepared));
physics.Engine.ShadowObjects.Register( physics.Engine.ShadowObjects.Register(
77u, 77u,
@ -512,16 +611,17 @@ public sealed class RuntimePhysicsStateTests
RuntimeCollisionGenerationCommit spawned = RuntimeCollisionGenerationCommit spawned =
physics.CommitCollisionGeneration(admission, prepared); physics.CommitCollisionGeneration(admission, prepared);
Assert.False(spawned.Committed); 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); physics.Engine.ShadowObjects.Deregister(77u);
RuntimeCollisionGenerationCommit deleted = RuntimeCollisionGenerationCommit deleted =
physics.CommitCollisionGeneration(admission, prepared); physics.CommitCollisionGeneration(admission, prepared);
Assert.False(deleted.Committed); 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( Assert.True(physics.CommitCollisionGeneration(
admission, admission,
prepared).Committed); prepared).Committed);
@ -546,7 +646,7 @@ public sealed class RuntimePhysicsStateTests
admission, admission,
prepared, prepared,
CollisionAssets(0x0101FFFFu)); CollisionAssets(0x0101FFFFu));
_ = lifetime.Physics.CommitCollisionGeneration(admission, prepared); _ = CommitPrepared(lifetime.Physics, admission, prepared);
lifetime.Physics.Engine.ShadowObjects.Register( lifetime.Physics.Engine.ShadowObjects.Register(
entityId: record.LocalEntityId!.Value, entityId: record.LocalEntityId!.Value,
gfxObjId: 0x01000001u, gfxObjId: 0x01000001u,
@ -930,6 +1030,49 @@ public sealed class RuntimePhysicsStateTests
Assert.Equal(record.SpatialAuthorityVersion, observed.SpatialAuthorityVersion); 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( private static RuntimeLandblockCollisionAssets CollisionAssets(
uint landblockId, uint landblockId,
float terrainHeight = 0f) float terrainHeight = 0f)