fix(physics): activate collision generations atomically

This commit is contained in:
Erik 2026-07-31 15:19:25 +02:00
parent 3e0f3b6206
commit be94bc9b06
18 changed files with 1402 additions and 80 deletions

View file

@ -485,8 +485,21 @@ What exists and is active:
collision assets loaded from the validated prepared package. Production collision assets loaded from the validated prepared package. Production
retains no parsed DAT collision graph; graph construction is restricted to retains no parsed DAT collision graph; graph construction is restricted to
bake/equivalence tools and explicit test oracles. bake/equivalence tools and explicit test oracles.
- `ShadowObjectRegistry` gives movement a broadphase over nearby objects and - Landblock collision activation is generation-owned by
buildings. `RuntimePhysicsState`. Graphical and no-window hosts populate a private
`PreparedLandblockCollisionGeneration` over bounded cursors; its cache,
`CellGraph`, engine landblock, buildings, static shadows, and dynamic-owner
refloods are never visible through the borrowed live engine. Runtime validates
the exact admission plus every affected dynamic shadow-owner version, seals
the complete replacement, and commits it synchronously on the one update
thread before emitting `CollisionGenerationCommitted`. A moving, spawned, or
deleted owner makes the generation not-ready and returns only that dirty owner
to the cursor-budgeted refresh phase. Thus readers see the complete old
generation or complete new generation, never a mixed cell/cache/shadow world.
- `ShadowObjectRegistry` gives movement a per-cell broadphase over nearby
objects and buildings. Streaming reflood is structurally part of the Runtime
collision-generation commit; there is no independent post-publication
reflood suffix.
- `TerrainSurface` uses triangle-aware terrain contact; older "bilinear terrain - `TerrainSurface` uses triangle-aware terrain contact; older "bilinear terrain
Z" descriptions are historical B.3 language, not current architecture. Z" descriptions are historical B.3 language, not current architecture.

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 | Per-LANDBLOCK shadow re-flood on hydration vs retail per-CELL `recalc_cross_cells` | `src/AcDream.Core/Physics/ShadowObjectRegistry.cs:339` | The streaming unit IS the landblock; one hook per hydration event covers both race directions (entity-before-cells, cells-after-spawn) | Any cell-hydration path that doesn't raise the landblock hook leaves an entity's shadow set stale — walk-through / missing collisions in just-streamed cells | `CObjCell::init_objects``recalc_cross_cells`, 0x0052b420 / 0x00515a30 | | ~~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-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

@ -0,0 +1,72 @@
# Atomic collision-generation activation (Slice 3B)
## Retail anchor
Retail hydrates a cell synchronously. `CObjCell::init_objects`
(`0x0052B420`) visits objects associated with that cell and invokes
`CPhysicsObj::recalc_cross_cells` (`0x00515A30`). The final position path also
replaces shadows as one `SetPositionInternal` operation (`0x00515330`). Retail
therefore never exposes a world where the new cell exists but the objects that
overlap it still have their old cross-cell set.
Acdream streams a landblock over several update frames. Literal per-cell
mutation during those frames was not equivalent: the active `PhysicsDataCache`,
`CellGraph`, `PhysicsEngine`, buildings, static shadows, and retained-object
refloods changed at different cursors. Collision queries could observe a mixed
generation, and correctness depended on a later optional landblock callback.
## Ported adaptation
The asynchronous unit is now one Runtime-owned collision generation:
1. `BeginCollisionAdmission` issues the exact Runtime/landblock generation.
2. `PrepareCollisionGeneration` clones the bounded resident spatial records
into a private cache, graph, engine, and shadow registry. Global immutable
GfxObj/Setup catalogs are not copied; the accepted build's exact closure is
populated by the existing cursors.
3. App and Headless publish terrain, EnvCells, topology, buildings, prepared
collision assets, static owners, and retained dynamic-owner cell sets only
into that private generation.
4. Each dynamic owner refresh captures its exact `ShadowObjectRegistry`
mutation version. Movement, state/payload change, spawn, suspension, or
deletion changes that version.
5. `CommitCollisionGeneration` validates the admission, exact affected-owner
set, and all captured versions. If any owner is dirty, it returns the sorted
dirty IDs without touching the active world; App advances those IDs through
its existing work meter and retries.
6. Once fresh, Runtime precomputes the replacement arrays and synchronously
replaces the landblock's cache/graph/engine/building/static and dynamic
shadow state on the same update thread. Only after the complete replacement
does it emit `CollisionGenerationCommitted` and a ready acknowledgement.
The stable borrowed `PhysicsEngine` and `PhysicsDataCache` object identities do
not change. Presentation and no-window hosts use the same Runtime transaction.
Network workers still enqueue immutable messages and cannot mutate collision or
shadow state.
## Failure and lifetime rules
- A newer admission invalidates an older prepared generation.
- Demotion, withdrawal, reset, and disposal invalidate the admission before
changing the active generation.
- Disposing a stale/cancelled prepared generation clears only its private
engine/cache/shadows.
- The prior complete generation remains queryable throughout preparation.
- The commit notification is the future lost-cell-registry seam. Slice 3B does
not implement `GotoLostCell` or change `SetPosition` recovery behavior.
## Deterministic evidence
The focused Runtime/App tests pin:
- previous terrain/cells/buildings/statics remain visible until commit;
- exactly one notification after a successful complete activation;
- stale admission replacement has no active-world side effect;
- movement during staging rejects, refreshes only the dirty owner, and then
installs its latest cell set;
- spawn and deletion during staging both reject stale activation;
- graphical and no-window publishers use the same Runtime transaction;
- removal and terminal teardown converge the active ownership ledger.
This retires divergence row AD-6. The remaining lost-cell state-machine work is
deliberately outside this slice.

View file

@ -10,6 +10,14 @@ as "delete everything and start over." A partial retail transition port exists:
- `TransitionTypes` carries the active `SpherePath`, `CollisionInfo`, - `TransitionTypes` carries the active `SpherePath`, `CollisionInfo`,
transition, step, contact, and partial slide logic. transition, step, contact, and partial slide logic.
- `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.
`PreparedLandblockCollisionGeneration` owns an off-side cache, CellGraph,
engine snapshot, buildings, statics, and versioned dynamic-owner refloods;
`RuntimePhysicsState.CommitCollisionGeneration` validates freshness and
activates the complete generation on the single update thread. A dirty live
owner returns to the App cursor for refresh. The old generation remains
queryable until that commit. See
`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

@ -12,7 +12,7 @@ namespace AcDream.App.Streaming;
/// publication. The render publisher commits buildings and EnvCells between /// publication. The render publisher commits buildings and EnvCells between
/// these stages without recomputing the captured origin. /// these stages without recomputing the captured origin.
/// </summary> /// </summary>
public sealed class LandblockPhysicsPublication public sealed class LandblockPhysicsPublication : IDisposable
{ {
internal LandblockPhysicsPublication( internal LandblockPhysicsPublication(
object owner, object owner,
@ -21,7 +21,8 @@ public sealed class LandblockPhysicsPublication
uint currentCellId, uint currentCellId,
BuildingInfo[] buildings, BuildingInfo[] buildings,
uint[] priorStaticOwnerIds, uint[] priorStaticOwnerIds,
RuntimeCollisionAdmission collisionAdmission) RuntimeCollisionAdmission collisionAdmission,
PreparedLandblockCollisionGeneration preparedGeneration)
{ {
Owner = owner; Owner = owner;
Build = build; Build = build;
@ -30,6 +31,7 @@ public sealed class LandblockPhysicsPublication
Buildings = buildings; Buildings = buildings;
PriorStaticOwnerIds = priorStaticOwnerIds; PriorStaticOwnerIds = priorStaticOwnerIds;
CollisionAdmission = collisionAdmission; CollisionAdmission = collisionAdmission;
PreparedGeneration = preparedGeneration;
} }
internal object Owner { get; } internal object Owner { get; }
@ -38,6 +40,9 @@ public sealed class LandblockPhysicsPublication
internal BuildingInfo[] Buildings { get; } internal BuildingInfo[] Buildings { get; }
internal uint[] PriorStaticOwnerIds { get; } internal uint[] PriorStaticOwnerIds { get; }
internal RuntimeCollisionAdmission CollisionAdmission { get; } internal RuntimeCollisionAdmission CollisionAdmission { get; }
internal PreparedLandblockCollisionGeneration PreparedGeneration { get; }
internal PhysicsDataCache StagingCache => PreparedGeneration.DataCache;
internal PhysicsEngine StagingEngine => PreparedGeneration.Engine;
internal SortedSet<uint> GfxObjectIdSet { get; } = new(); internal SortedSet<uint> GfxObjectIdSet { get; } = new();
internal uint[] GfxObjectIds { get; set; } = Array.Empty<uint>(); internal uint[] GfxObjectIds { get; set; } = Array.Empty<uint>();
internal int PreparationCursor { get; set; } internal int PreparationCursor { get; set; }
@ -64,6 +69,12 @@ public sealed class LandblockPhysicsPublication
internal bool BeginCommitted { get; set; } internal bool BeginCommitted { get; set; }
internal bool CompletionCommitted { get; set; } internal bool CompletionCommitted { get; set; }
public void Dispose()
{
if (!CompletionCommitted)
PreparedGeneration.Dispose();
}
public uint LandblockId => Build.Landblock.LandblockId; public uint LandblockId => Build.Landblock.LandblockId;
public Vector3 Origin { get; } public Vector3 Origin { get; }
} }
@ -204,6 +215,8 @@ public sealed class LandblockPhysicsPublisher
build.Landblock.PhysicsDats ?? PhysicsDatBundle.Empty; build.Landblock.PhysicsDats ?? PhysicsDatBundle.Empty;
BuildingInfo[] buildings = datBundle.Info?.Buildings.ToArray() BuildingInfo[] buildings = datBundle.Info?.Buildings.ToArray()
?? Array.Empty<BuildingInfo>(); ?? Array.Empty<BuildingInfo>();
RuntimeCollisionAdmission collisionAdmission =
_physics.BeginCollisionAdmission(build.Landblock.LandblockId);
var publication = new LandblockPhysicsPublication( var publication = new LandblockPhysicsPublication(
_receiptOwner, _receiptOwner,
build, build,
@ -212,11 +225,14 @@ public sealed class LandblockPhysicsPublisher
buildings, buildings,
_physicsEngine.ShadowObjects.CaptureStaticOwnersForLandblock( _physicsEngine.ShadowObjects.CaptureStaticOwnersForLandblock(
build.Landblock.LandblockId), build.Landblock.LandblockId),
_physics.BeginCollisionAdmission( collisionAdmission,
build.Landblock.LandblockId)); _physics.PrepareCollisionGeneration(collisionAdmission));
publication.SetupObjectIds = build.Collisions is { } collisions publication.SetupObjectIds = build.Collisions is { } collisions
? [.. collisions.SetupIds] ? [.. collisions.SetupIds]
: datBundle.Setups.Keys.Order().ToArray(); : datBundle.Setups.Keys.Order().ToArray();
publication.PreparedGeneration.SetAssetClosure(
publication.GfxObjectIds,
publication.SetupObjectIds);
return publication; return publication;
} }
@ -237,6 +253,9 @@ public sealed class LandblockPhysicsPublisher
if (publication.Build.Collisions is { } collisions) if (publication.Build.Collisions is { } collisions)
{ {
publication.GfxObjectIds = [.. collisions.GfxObjIds]; publication.GfxObjectIds = [.. collisions.GfxObjIds];
publication.PreparedGeneration.SetAssetClosure(
publication.GfxObjectIds,
publication.SetupObjectIds);
publication.PreparationCursor = entities.Count; publication.PreparationCursor = entities.Count;
publication.PreparationCommitted = true; publication.PreparationCommitted = true;
return true; return true;
@ -256,6 +275,9 @@ public sealed class LandblockPhysicsPublisher
} }
publication.GfxObjectIds = publication.GfxObjectIdSet.ToArray(); publication.GfxObjectIds = publication.GfxObjectIdSet.ToArray();
publication.PreparedGeneration.SetAssetClosure(
publication.GfxObjectIds,
publication.SetupObjectIds);
publication.PreparationCommitted = true; publication.PreparationCommitted = true;
return true; return true;
} }
@ -299,9 +321,9 @@ public sealed class LandblockPhysicsPublisher
// CacheCellStruct/CacheBuilding use first-wins semantics within one // CacheCellStruct/CacheBuilding use first-wins semantics within one
// publication, so the replacement pass starts with one exact // publication, so the replacement pass starts with one exact
// landblock-scoped withdrawal. // landblock-scoped withdrawal.
_physicsDataCache.RemoveCellsForLandblock(landblock.LandblockId); publication.StagingCache.RemoveCellsForLandblock(landblock.LandblockId);
_physicsDataCache.RemoveBuildingsForLandblock(landblock.LandblockId); publication.StagingCache.RemoveBuildingsForLandblock(landblock.LandblockId);
_physicsDataCache.CellGraph.RemoveEnvCellsForLandblock( publication.StagingCache.CellGraph.RemoveEnvCellsForLandblock(
landblock.LandblockId); landblock.LandblockId);
publication.PriorCacheRemoved = true; publication.PriorCacheRemoved = true;
} }
@ -330,6 +352,7 @@ public sealed class LandblockPhysicsPublisher
PublishBuilding( PublishBuilding(
landblock, landblock,
datBundle, datBundle,
publication.StagingCache,
publication.TerrainSurface, publication.TerrainSurface,
origin, origin,
publication.Buildings[publication.BuildingCursor]); publication.Buildings[publication.BuildingCursor]);
@ -337,8 +360,9 @@ public sealed class LandblockPhysicsPublisher
} }
else if (!publication.BaseCommitted) else if (!publication.BaseCommitted)
{ {
_physics.AdmitCollisionAssets( _physics.StageCollisionAssets(
publication.CollisionAdmission, publication.CollisionAdmission,
publication.PreparedGeneration,
new RuntimeLandblockCollisionAssets( new RuntimeLandblockCollisionAssets(
landblock.LandblockId, landblock.LandblockId,
publication.TerrainSurface, publication.TerrainSurface,
@ -409,7 +433,7 @@ public sealed class LandblockPhysicsPublisher
gfxObjectId, gfxObjectId,
out FlatGfxObjCollisionAsset? prepared) == true) out FlatGfxObjCollisionAsset? prepared) == true)
{ {
_physicsDataCache.CacheGfxObj(gfxObjectId, prepared); publication.StagingCache.CacheGfxObj(gfxObjectId, prepared);
} }
else if (datBundle.GfxObjs.TryGetValue( else if (datBundle.GfxObjs.TryGetValue(
gfxObjectId, gfxObjectId,
@ -417,7 +441,7 @@ public sealed class LandblockPhysicsPublisher
{ {
// Graph-oracle fixture seam. Production near builds always // Graph-oracle fixture seam. Production near builds always
// carry the strict prepared closure. // carry the strict prepared closure.
_physicsDataCache.CacheGfxObj(gfxObjectId, source); publication.StagingCache.CacheGfxObj(gfxObjectId, source);
} }
publication.GfxCursor++; publication.GfxCursor++;
_gfxCacheTicks += Stopwatch.GetTimestamp() - cacheStarted; _gfxCacheTicks += Stopwatch.GetTimestamp() - cacheStarted;
@ -430,19 +454,19 @@ public sealed class LandblockPhysicsPublisher
setupId, setupId,
out FlatSetupCollision? prepared) == true) out FlatSetupCollision? prepared) == true)
{ {
_physicsDataCache.CacheSetup(setupId, prepared); publication.StagingCache.CacheSetup(setupId, prepared);
} }
else if (datBundle.Setups.TryGetValue(setupId, out var source)) else if (datBundle.Setups.TryGetValue(setupId, out var source))
{ {
// Graph-oracle fixture seam only. // Graph-oracle fixture seam only.
_physicsDataCache.CacheSetup(setupId, source); publication.StagingCache.CacheSetup(setupId, source);
} }
publication.SetupCursor++; publication.SetupCursor++;
} }
else if (publication.PriorStaticCursor else if (publication.PriorStaticCursor
< publication.PriorStaticOwnerIds.Length) < publication.PriorStaticOwnerIds.Length)
{ {
_physicsEngine.ShadowObjects.DeregisterStaticOwnerForLandblock( publication.StagingEngine.ShadowObjects.DeregisterStaticOwnerForLandblock(
publication.PriorStaticOwnerIds[ publication.PriorStaticOwnerIds[
publication.PriorStaticCursor], publication.PriorStaticCursor],
landblock.LandblockId); landblock.LandblockId);
@ -458,15 +482,17 @@ public sealed class LandblockPhysicsPublisher
else if (publication.RefloodOwnerIds is null) else if (publication.RefloodOwnerIds is null)
{ {
publication.RefloodOwnerIds = publication.RefloodOwnerIds =
_physicsEngine.ShadowObjects _physics.CaptureCollisionDynamicOwners(
.CaptureRefloodOwnersForLandblock(landblock.LandblockId); publication.CollisionAdmission,
publication.PreparedGeneration);
} }
else if (publication.RefloodCursor else if (publication.RefloodCursor
< publication.RefloodOwnerIds.Length) < publication.RefloodOwnerIds.Length)
{ {
_physicsEngine.ShadowObjects.RefloodOwnerForLandblock( _physics.RefreshCollisionDynamicOwner(
publication.RefloodOwnerIds[publication.RefloodCursor], publication.CollisionAdmission,
landblock.LandblockId); publication.PreparedGeneration,
publication.RefloodOwnerIds[publication.RefloodCursor]);
publication.RefloodCursor++; publication.RefloodCursor++;
} }
else if (!publication.RefloodCommitted) else if (!publication.RefloodCommitted)
@ -478,14 +504,24 @@ public sealed class LandblockPhysicsPublisher
$"lb 0x{landblock.LandblockId:X8}: scenery tried={publication.SceneryTried} " + $"lb 0x{landblock.LandblockId:X8}: scenery tried={publication.SceneryTried} " +
$"(outdoorNone={publication.NoCollisionCount})"); $"(outdoorNone={publication.NoCollisionCount})");
} }
LogMissingSceneryBounds(landblock); LogMissingSceneryBounds(landblock, publication.StagingCache);
_refloodCount++;
publication.RefloodCommitted = true; publication.RefloodCommitted = true;
} }
else else
{ {
_physics.CompleteCollisionAdmission( RuntimeCollisionGenerationCommit commit =
publication.CollisionAdmission); _physics.CommitCollisionGeneration(
publication.CollisionAdmission,
publication.PreparedGeneration);
if (!commit.Committed)
{
publication.RefloodOwnerIds = commit.DirtyDynamicOwnerIds;
publication.RefloodCursor = 0;
publication.RefloodCommitted = false;
_completePublishTicks += Stopwatch.GetTimestamp() - started;
return false;
}
_refloodCount++;
_staticBspOwnerCount += publication.BspOwnerCount; _staticBspOwnerCount += publication.BspOwnerCount;
_staticCylinderOwnerCount += publication.CylinderOwnerCount; _staticCylinderOwnerCount += publication.CylinderOwnerCount;
publication.CompletionCommitted = true; publication.CompletionCommitted = true;
@ -545,7 +581,7 @@ public sealed class LandblockPhysicsPublisher
Matrix4x4.CreateFromQuaternion(rotation) Matrix4x4.CreateFromQuaternion(rotation)
* Matrix4x4.CreateTranslation(cellOriginWorld); * Matrix4x4.CreateTranslation(cellOriginWorld);
_physicsDataCache.CacheCellStruct( publication.StagingCache.CacheCellStruct(
envCellId, envCellId,
envCell, envCell,
physicsCellTransform, physicsCellTransform,
@ -623,7 +659,7 @@ public sealed class LandblockPhysicsPublisher
Matrix4x4 physicsCellTransform = Matrix4x4 physicsCellTransform =
Matrix4x4.CreateFromQuaternion(rotation) Matrix4x4.CreateFromQuaternion(rotation)
* Matrix4x4.CreateTranslation(cellOriginWorld); * Matrix4x4.CreateTranslation(cellOriginWorld);
_physicsDataCache.CacheCellStruct( publication.StagingCache.CacheCellStruct(
envCellId, envCellId,
envCell, envCell,
cellStruct, cellStruct,
@ -687,6 +723,7 @@ public sealed class LandblockPhysicsPublisher
private void PublishBuilding( private void PublishBuilding(
LoadedLandblock landblock, LoadedLandblock landblock,
PhysicsDatBundle datBundle, PhysicsDatBundle datBundle,
PhysicsDataCache cache,
TerrainSurface terrainSurface, TerrainSurface terrainSurface,
Vector3 origin, Vector3 origin,
BuildingInfo building) BuildingInfo building)
@ -720,7 +757,7 @@ public sealed class LandblockPhysicsPublisher
? setup.Parts[0] ? setup.Parts[0]
: 0u; : 0u;
} }
_physicsDataCache.CacheBuilding( cache.CacheBuilding(
landcellId, landcellId,
portals, portals,
buildingTransform, buildingTransform,
@ -752,11 +789,11 @@ public sealed class LandblockPhysicsPublisher
ShadowShapeBuilder.FromLandblockBspParts( ShadowShapeBuilder.FromLandblockBspParts(
entity.MeshRefs, entity.MeshRefs,
entity.IsBuildingShell, entity.IsBuildingShell,
_physicsDataCache.GetGfxObj); publication.StagingCache.GetGfxObj);
entityBspCount = bspShapes.Count; entityBspCount = bspShapes.Count;
if (entityBspCount > 0) if (entityBspCount > 0)
{ {
_physicsEngine.ShadowObjects.RegisterMultiPart( publication.StagingEngine.ShadowObjects.RegisterMultiPart(
entity.Id, entity.Id,
entity.Position, entity.Position,
entity.Rotation, entity.Rotation,
@ -772,9 +809,9 @@ public sealed class LandblockPhysicsPublisher
} }
FlatSetupCollision? setup = FlatSetupCollision? setup =
_physicsDataCache.GetFlatSetup(entity.SourceGfxObjOrSetupId); publication.StagingCache.GetFlatSetup(entity.SourceGfxObjOrSetupId);
if (setup is null if (setup is null
&& _physicsDataCache.GetSetup( && publication.StagingCache.GetSetup(
entity.SourceGfxObjOrSetupId) is { } graphSetup) entity.SourceGfxObjOrSetupId) is { } graphSetup)
{ {
// Graph-oracle fixture seam only. Production Setup publication is // Graph-oracle fixture seam only. Production Setup publication is
@ -858,7 +895,7 @@ public sealed class LandblockPhysicsPublisher
if (setupShapes.Count > 0) if (setupShapes.Count > 0)
{ {
_physicsEngine.ShadowObjects.RegisterMultiPart( publication.StagingEngine.ShadowObjects.RegisterMultiPart(
entity.Id, entity.Id,
entity.Position, entity.Position,
entity.Rotation, entity.Rotation,
@ -917,7 +954,9 @@ public sealed class LandblockPhysicsPublisher
} }
} }
private void LogMissingSceneryBounds(LoadedLandblock landblock) private static void LogMissingSceneryBounds(
LoadedLandblock landblock,
PhysicsDataCache cache)
{ {
if (!PhysicsDiagnostics.ProbeBuildingEnabled) if (!PhysicsDiagnostics.ProbeBuildingEnabled)
return; return;
@ -933,7 +972,7 @@ public sealed class LandblockPhysicsPublisher
foreach (MeshRef meshRef in entity.MeshRefs) foreach (MeshRef meshRef in entity.MeshRefs)
{ {
GfxObjVisualBounds? bounds = GfxObjVisualBounds? bounds =
_physicsDataCache.GetVisualBounds(meshRef.GfxObjId); cache.GetVisualBounds(meshRef.GfxObjId);
if (bounds is not null && bounds.Radius > 0f) if (bounds is not null && bounds.Radius > 0f)
{ {
hasBounds = true; hasBounds = true;

View file

@ -215,6 +215,18 @@ public sealed class LandblockPresentationPipeline
public IReadOnlyList<LandblockStreamResult> GetPendingPublicationResults() => public IReadOnlyList<LandblockStreamResult> GetPendingPublicationResults() =>
_publications.Keys.ToArray(); _publications.Keys.ToArray();
/// <summary>
/// Cancels retained publication receipts during a generation reset. A
/// collision receipt owns only its private staging world until activation,
/// so cancellation cannot withdraw or partially replace the active world.
/// </summary>
internal void CancelPendingPublications()
{
foreach (PublicationTransaction transaction in _publications.Values)
transaction.PhysicsPublication?.Dispose();
_publications.Clear();
}
public void ResumePublication(LandblockStreamResult result) public void ResumePublication(LandblockStreamResult result)
{ {
ArgumentNullException.ThrowIfNull(result); ArgumentNullException.ThrowIfNull(result);

View file

@ -32,6 +32,7 @@ public sealed class StreamingController
public bool GenerationAdvanced; public bool GenerationAdvanced;
public bool PendingLoadsCleared; public bool PendingLoadsCleared;
public bool CompletionQueueCleared; public bool CompletionQueueCleared;
public bool PendingPublicationsCleared;
public bool RegionCleared; public bool RegionCleared;
public bool SpatialGenerationDetached; public bool SpatialGenerationDetached;
public bool PreparationCommitted; public bool PreparationCommitted;
@ -45,6 +46,7 @@ public sealed class StreamingController
public bool GenerationAdvanced; public bool GenerationAdvanced;
public bool PendingLoadsCleared; public bool PendingLoadsCleared;
public bool CompletionQueueCleared; public bool CompletionQueueCleared;
public bool PendingPublicationsCleared;
public bool RegionCleared; public bool RegionCleared;
public List<uint>? ResidentIds; public List<uint>? ResidentIds;
public IEnumerator<uint>? ResidentEnumerator; public IEnumerator<uint>? ResidentEnumerator;
@ -1358,6 +1360,22 @@ public sealed class StreamingController
} }
if (!transaction.RegionCleared) if (!transaction.RegionCleared)
{ {
if (!transaction.PendingPublicationsCleared)
{
if (!TryRunStreamingWork(
meter,
new StreamingWorkCost(EntityOperations: 1),
"recenter-cancel-publications",
() =>
{
_presentation.CancelPendingPublications();
transaction.PendingPublicationsCleared = true;
return true;
}))
{
return false;
}
}
if (!TryRunStreamingWork( if (!TryRunStreamingWork(
meter, meter,
new StreamingWorkCost(EntityOperations: 1), new StreamingWorkCost(EntityOperations: 1),
@ -1499,6 +1517,22 @@ public sealed class StreamingController
if (!transaction.RegionCleared) if (!transaction.RegionCleared)
{ {
if (!transaction.PendingPublicationsCleared)
{
if (!TryRunStreamingWork(
meter,
new StreamingWorkCost(EntityOperations: 1),
"reload-cancel-publications",
() =>
{
_presentation.CancelPendingPublications();
transaction.PendingPublicationsCleared = true;
return true;
}))
{
return false;
}
}
if (!TryRunStreamingWork( if (!TryRunStreamingWork(
meter, meter,
new StreamingWorkCost(EntityOperations: 1), new StreamingWorkCost(EntityOperations: 1),

View file

@ -20,6 +20,9 @@
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute"> <AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
<_Parameter1>AcDream.Core.Tests</_Parameter1> <_Parameter1>AcDream.Core.Tests</_Parameter1>
</AssemblyAttribute> </AssemblyAttribute>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
<_Parameter1>AcDream.Runtime</_Parameter1>
</AssemblyAttribute>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\AcDream.Plugin.Abstractions\AcDream.Plugin.Abstractions.csproj" /> <ProjectReference Include="..\AcDream.Plugin.Abstractions\AcDream.Plugin.Abstractions.csproj" />

View file

@ -6,6 +6,7 @@ using DatReaderWriter.Types;
using Plane = System.Numerics.Plane; using Plane = System.Numerics.Plane;
using UcgEnvCell = AcDream.Core.World.Cells.EnvCell; using UcgEnvCell = AcDream.Core.World.Cells.EnvCell;
using UcgCellGraph = AcDream.Core.World.Cells.CellGraph; using UcgCellGraph = AcDream.Core.World.Cells.CellGraph;
using PreparedCellGraphLandblock = AcDream.Core.World.Cells.PreparedCellGraphLandblock;
namespace AcDream.Core.Physics; namespace AcDream.Core.Physics;
@ -21,6 +22,7 @@ namespace AcDream.Core.Physics;
public sealed class PhysicsDataCache public sealed class PhysicsDataCache
{ {
private readonly bool _requirePreparedCollision; private readonly bool _requirePreparedCollision;
private PhysicsDataCache? _readFallback;
private readonly ConcurrentDictionary<uint, GfxObjPhysics> _gfxObj = new(); private readonly ConcurrentDictionary<uint, GfxObjPhysics> _gfxObj = new();
private readonly ConcurrentDictionary<uint, GfxObjVisualBounds> _visualBounds = new(); private readonly ConcurrentDictionary<uint, GfxObjVisualBounds> _visualBounds = new();
private readonly ConcurrentDictionary<uint, SetupPhysics> _setup = new(); private readonly ConcurrentDictionary<uint, SetupPhysics> _setup = new();
@ -92,7 +94,112 @@ public sealed class PhysicsDataCache
/// (<c>TryGetTerrainOrigin</c>, read by <c>CellTransit</c>'s pick + transit /// (<c>TryGetTerrainOrigin</c>, read by <c>CellTransit</c>'s pick + transit
/// paths). No longer inert. /// paths). No longer inert.
/// </summary> /// </summary>
public UcgCellGraph CellGraph { get; } = new(); public UcgCellGraph CellGraph { get; private set; } = new();
/// <summary>
/// Copies the currently committed immutable collision records into an
/// off-side cache. Streaming may replace one landblock in this copy over
/// many frames without exposing a partially withdrawn cell graph to live
/// physics queries.
/// </summary>
internal PhysicsDataCache CreateCollisionStagingCopy()
{
var copy = new PhysicsDataCache(_requirePreparedCollision)
{
CollisionTraversalMode = CollisionTraversalMode,
CellGraph = CellGraph.CreateCollisionStagingCopy(),
_readFallback = this,
};
// Global immutable GfxObj/Setup records are not copied wholesale.
// The accepted build's exact closure is staged cursor-by-cursor below;
// copying the process-retained asset catalog here would turn every
// landblock publication into an unbounded frame spike.
CopyDictionary(_cellStruct, copy._cellStruct);
CopyDictionary(_flatCellStruct, copy._flatCellStruct);
CopyDictionary(_flatEnvCell, copy._flatEnvCell);
CopyDictionary(_buildings, copy._buildings);
return copy;
}
internal PreparedPhysicsDataCacheLandblock PrepareLandblockReplacement(
uint landblockId,
ReadOnlySpan<uint> gfxObjectIds,
ReadOnlySpan<uint> setupIds)
{
uint prefix = landblockId & 0xFFFF0000u;
return new PreparedPhysicsDataCacheLandblock(
prefix,
CaptureRequested(_gfxObj, gfxObjectIds),
CaptureRequested(_visualBounds, gfxObjectIds),
CaptureRequested(_flatGfxObj, gfxObjectIds),
CaptureRequested(_setup, setupIds),
CaptureRequested(_flatSetup, setupIds),
CapturePrefix(_cellStruct, prefix),
CapturePrefix(_flatCellStruct, prefix),
CapturePrefix(_flatEnvCell, prefix),
CapturePrefix(_buildings, prefix),
CellGraph.PrepareLandblockReplacement(prefix));
}
internal void CommitLandblockReplacement(
PreparedPhysicsDataCacheLandblock replacement)
{
RemoveCellsForLandblock(replacement.LandblockPrefix);
RemoveBuildingsForLandblock(replacement.LandblockPrefix);
CommitEntries(_gfxObj, replacement.GfxObjects, replace: false);
CommitEntries(_visualBounds, replacement.VisualBounds, replace: false);
CommitEntries(_flatGfxObj, replacement.FlatGfxObjects, replace: false);
CommitEntries(_setup, replacement.Setups, replace: false);
CommitEntries(_flatSetup, replacement.FlatSetups, replace: false);
CommitEntries(_cellStruct, replacement.Cells, replace: true);
CommitEntries(_flatCellStruct, replacement.FlatCells, replace: true);
CommitEntries(_flatEnvCell, replacement.FlatEnvCells, replace: true);
CommitEntries(_buildings, replacement.Buildings, replace: true);
CellGraph.CommitLandblockReplacement(replacement.CellGraph);
}
private static void CopyDictionary<T>(
ConcurrentDictionary<uint, T> source,
ConcurrentDictionary<uint, T> destination)
{
foreach ((uint id, T value) in source)
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>(
ConcurrentDictionary<uint, T> destination,
KeyValuePair<uint, T>[] entries,
bool replace)
{
foreach ((uint id, T value) in entries)
{
if (replace)
destination[id] = value;
else
destination.TryAdd(id, value);
}
}
/// <summary> /// <summary>
/// Extract and cache the physics BSP + polygon data from a GfxObj, /// Extract and cache the physics BSP + polygon data from a GfxObj,
@ -237,7 +344,9 @@ public sealed class PhysicsDataCache
/// Get the cached visual AABB for a GfxObj, or null if not cached. /// Get the cached visual AABB for a GfxObj, or null if not cached.
/// </summary> /// </summary>
public GfxObjVisualBounds? GetVisualBounds(uint gfxObjId) => public GfxObjVisualBounds? GetVisualBounds(uint gfxObjId) =>
_visualBounds.TryGetValue(gfxObjId, out var vb) ? vb : null; _visualBounds.TryGetValue(gfxObjId, out var vb)
? vb
: _readFallback?.GetVisualBounds(gfxObjId);
/// <summary> /// <summary>
/// Compute a tight axis-aligned bounding box over all vertices in the mesh. /// Compute a tight axis-aligned bounding box over all vertices in the mesh.
@ -756,14 +865,24 @@ public sealed class PhysicsDataCache
$"Production {kind} 0x{sourceId:X8} has no prepared collision asset. " + $"Production {kind} 0x{sourceId:X8} has no prepared collision asset. " +
"Gameplay must not extract or fall back to a parsed DAT graph."); "Gameplay must not extract or fall back to a parsed DAT graph.");
public GfxObjPhysics? GetGfxObj(uint id) => _gfxObj.TryGetValue(id, out var p) ? p : null; public GfxObjPhysics? GetGfxObj(uint id) =>
_gfxObj.TryGetValue(id, out var p)
? p
: _readFallback?.GetGfxObj(id);
public SetupPhysics? GetSetup(uint id) => _setup.TryGetValue(id, out var p) ? p : null; public SetupPhysics? GetSetup(uint id) =>
_setup.TryGetValue(id, out var p)
? p
: _readFallback?.GetSetup(id);
public CellPhysics? GetCellStruct(uint id) => _cellStruct.TryGetValue(id, out var p) ? p : null; public CellPhysics? GetCellStruct(uint id) => _cellStruct.TryGetValue(id, out var p) ? p : null;
public FlatGfxObjCollisionAsset? GetFlatGfxObj(uint id) => public FlatGfxObjCollisionAsset? GetFlatGfxObj(uint id) =>
_flatGfxObj.TryGetValue(id, out var value) ? value : null; _flatGfxObj.TryGetValue(id, out var value)
? value
: _readFallback?.GetFlatGfxObj(id);
public FlatSetupCollision? GetFlatSetup(uint id) => public FlatSetupCollision? GetFlatSetup(uint id) =>
_flatSetup.TryGetValue(id, out var value) ? value : null; _flatSetup.TryGetValue(id, out var value)
? value
: _readFallback?.GetFlatSetup(id);
public FlatCellStructureCollisionAsset? GetFlatCellStruct(uint id) => public FlatCellStructureCollisionAsset? GetFlatCellStruct(uint id) =>
_flatCellStruct.TryGetValue(id, out var value) ? value : null; _flatCellStruct.TryGetValue(id, out var value) ? value : null;
public FlatEnvCellTopology? GetFlatEnvCell(uint id) => public FlatEnvCellTopology? GetFlatEnvCell(uint id) =>
@ -926,6 +1045,19 @@ public sealed class PhysicsDataCache
public void RegisterBuildingForTest(uint landcellId, BuildingPhysics b) => _buildings[landcellId] = b; public void RegisterBuildingForTest(uint landcellId, BuildingPhysics b) => _buildings[landcellId] = b;
} }
internal sealed record PreparedPhysicsDataCacheLandblock(
uint LandblockPrefix,
KeyValuePair<uint, GfxObjPhysics>[] GfxObjects,
KeyValuePair<uint, GfxObjVisualBounds>[] VisualBounds,
KeyValuePair<uint, FlatGfxObjCollisionAsset>[] FlatGfxObjects,
KeyValuePair<uint, SetupPhysics>[] Setups,
KeyValuePair<uint, FlatSetupCollision>[] FlatSetups,
KeyValuePair<uint, CellPhysics>[] Cells,
KeyValuePair<uint, FlatCellStructureCollisionAsset>[] FlatCells,
KeyValuePair<uint, FlatEnvCellTopology>[] FlatEnvCells,
KeyValuePair<uint, BuildingPhysics>[] Buildings,
PreparedCellGraphLandblock CellGraph);
/// <summary> /// <summary>
/// Visual AABB of a GfxObj mesh — populated for every cached GfxObj regardless /// Visual AABB of a GfxObj mesh — populated for every cached GfxObj regardless
/// of whether it has physics data. Used as a collision fallback shape for /// of whether it has physics data. Used as a collision fallback shape for

View file

@ -153,13 +153,106 @@ public sealed class PhysicsEngine
/// </summary> /// </summary>
public ClientObjectTable? Objects { get; set; } public ClientObjectTable? Objects { get; set; }
private sealed record LandblockPhysics( internal sealed record LandblockPhysics(
TerrainSurface Terrain, TerrainSurface Terrain,
IReadOnlyList<CellSurface> Cells, IReadOnlyList<CellSurface> Cells,
IReadOnlyList<PortalPlane> Portals, IReadOnlyList<PortalPlane> Portals,
float WorldOffsetX, float WorldOffsetX,
float WorldOffsetY); float WorldOffsetY);
/// <summary>
/// Creates an off-side collision world from the last complete generation.
/// Streaming modifies this copy only; the active engine and its borrowed
/// cache/registry identities remain stable until Runtime commits.
/// </summary>
internal PhysicsEngine CreateCollisionStagingCopy(
PhysicsDataCache stagingCache)
{
ArgumentNullException.ThrowIfNull(stagingCache);
var staging = new PhysicsEngine
{
DataCache = stagingCache,
Objects = Objects,
};
foreach ((uint id, LandblockPhysics landblock) in _landblocks)
staging._landblocks[id] = landblock;
staging.ShadowObjects.CopyCollisionStateFrom(
ShadowObjects,
stagingCache);
return staging;
}
internal PreparedPhysicsEngineLandblock PrepareLandblockReplacement(
PhysicsEngine staging,
uint landblockId,
ReadOnlySpan<uint> gfxObjectIds,
ReadOnlySpan<uint> setupIds,
IReadOnlyDictionary<uint, ulong> expectedDynamicVersions)
{
ArgumentNullException.ThrowIfNull(staging);
uint canonical = (landblockId & 0xFFFF0000u) | 0xFFFFu;
if (!staging._landblocks.TryGetValue(
canonical,
out LandblockPhysics? landblock))
{
throw new InvalidOperationException(
$"Staging collision generation has no landblock 0x{canonical:X8}.");
}
PhysicsDataCache stagingCache = staging.DataCache
?? throw new InvalidOperationException(
"Staging collision engine has no data cache.");
return new PreparedPhysicsEngineLandblock(
canonical,
landblock,
stagingCache.PrepareLandblockReplacement(
canonical,
gfxObjectIds,
setupIds),
ShadowObjects.PrepareLandblockReplacement(
staging.ShadowObjects,
canonical,
expectedDynamicVersions));
}
internal bool ValidateLandblockReplacement(
PreparedPhysicsEngineLandblock replacement) =>
ShadowObjects.ValidateLandblockReplacement(replacement.Shadows);
internal void CommitLandblockReplacement(
PreparedPhysicsEngineLandblock replacement)
{
if (!ValidateLandblockReplacement(replacement))
{
throw new InvalidOperationException(
"Collision generation changed after it was sealed.");
}
(DataCache ?? throw new InvalidOperationException(
"Active collision engine has no data cache."))
.CommitLandblockReplacement(replacement.DataCache);
_landblocks[replacement.LandblockId] = replacement.Landblock;
ShadowObjects.CommitLandblockReplacement(replacement.Shadows);
}
internal sealed class PreparedPhysicsEngineLandblock
{
internal PreparedPhysicsEngineLandblock(
uint landblockId,
LandblockPhysics landblock,
PreparedPhysicsDataCacheLandblock dataCache,
ShadowObjectRegistry.PreparedLandblockShadowReplacement shadows)
{
LandblockId = landblockId;
Landblock = landblock;
DataCache = dataCache;
Shadows = shadows;
}
internal uint LandblockId { get; }
internal LandblockPhysics Landblock { get; }
internal PreparedPhysicsDataCacheLandblock DataCache { get; }
internal ShadowObjectRegistry.PreparedLandblockShadowReplacement Shadows { get; }
}
/// <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

@ -51,8 +51,9 @@ public sealed class ShadowObjectRegistry
/// is the streaming-side trigger. /// is the streaming-side trigger.
/// </summary> /// </summary>
private readonly Dictionary<uint, RegistrationRecord> _entityReg = new(); private readonly Dictionary<uint, RegistrationRecord> _entityReg = new();
private readonly Dictionary<uint, ulong> _ownerVersions = new();
private sealed record RegistrationRecord( internal sealed record RegistrationRecord(
uint SeedCellId, uint SeedCellId,
Vector3 EntityWorldPos, Vector3 EntityWorldPos,
Quaternion EntityWorldRot, Quaternion EntityWorldRot,
@ -67,6 +68,16 @@ public sealed class ShadowObjectRegistry
float CylHeight, float CylHeight,
float Scale); float Scale);
internal ulong GetOwnerVersion(uint entityId) =>
_ownerVersions.TryGetValue(entityId, out ulong version)
? version
: 0UL;
private void BumpOwnerVersion(uint entityId)
{
_ownerVersions[entityId] = checked(GetOwnerVersion(entityId) + 1UL);
}
/// <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.
@ -135,6 +146,7 @@ public sealed class ShadowObjectRegistry
_entityReg[entityId] = new RegistrationRecord( _entityReg[entityId] = new RegistrationRecord(
seed, worldPos, rotation, state, flags, isStatic, seed, worldPos, rotation, state, flags, isStatic,
IsMultiPart: false, gfxObjId, radius, collisionType, cylHeight, scale); IsMultiPart: false, gfxObjId, radius, collisionType, cylHeight, scale);
BumpOwnerVersion(entityId);
} }
/// <summary> /// <summary>
@ -214,6 +226,7 @@ public sealed class ShadowObjectRegistry
seed, entityWorldPos, entityWorldRot, state, flags, isStatic, seed, entityWorldPos, entityWorldRot, state, flags, isStatic,
IsMultiPart: true, GfxObjId: 0u, Radius: 0f, IsMultiPart: true, GfxObjId: 0u, Radius: 0f,
CollisionType: ShadowCollisionType.BSP, CylHeight: 0f, Scale: 1f); CollisionType: ShadowCollisionType.BSP, CylHeight: 0f, Scale: 1f);
BumpOwnerVersion(entityId);
} }
/// <summary> /// <summary>
@ -271,7 +284,10 @@ public sealed class ShadowObjectRegistry
}; };
if (suspended || !_entityToCells.TryGetValue(entityId, out List<uint>? cells)) if (suspended || !_entityToCells.TryGetValue(entityId, out List<uint>? cells))
{
BumpOwnerVersion(entityId);
return; return;
}
foreach (uint cellId in cells) foreach (uint cellId in cells)
{ {
@ -300,6 +316,7 @@ public sealed class ShadowObjectRegistry
foreach (uint cellId in cells) foreach (uint cellId in cells)
AddEntryToCell(entry, cellId); AddEntryToCell(entry, cellId);
} }
BumpOwnerVersion(entityId);
} }
/// <summary> /// <summary>
@ -441,6 +458,7 @@ public sealed class ShadowObjectRegistry
} }
_suspendedEntities.Add(entityId); _suspendedEntities.Add(entityId);
BumpOwnerVersion(entityId);
return true; return true;
} }
@ -624,11 +642,16 @@ public sealed class ShadowObjectRegistry
if (_entityReg.TryGetValue(entityId, out var reg)) if (_entityReg.TryGetValue(entityId, out var reg))
_entityReg[entityId] = reg with { State = newState }; _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>
public void Deregister(uint entityId) public void Deregister(uint entityId)
{ {
bool existed = _entityReg.ContainsKey(entityId)
|| _entityToCells.ContainsKey(entityId)
|| _entityShapes.ContainsKey(entityId)
|| _suspendedEntities.Contains(entityId);
if (_entityToCells.TryGetValue(entityId, out var cellIds)) if (_entityToCells.TryGetValue(entityId, out var cellIds))
{ {
foreach (var cellId in cellIds) foreach (var cellId in cellIds)
@ -642,6 +665,8 @@ public sealed class ShadowObjectRegistry
_entityReg.Remove(entityId); _entityReg.Remove(entityId);
_suspendedEntities.Remove(entityId); _suspendedEntities.Remove(entityId);
_withdrawnPrefixesByOwner.Remove(entityId); _withdrawnPrefixesByOwner.Remove(entityId);
if (existed)
BumpOwnerVersion(entityId);
} }
/// <summary> /// <summary>
@ -708,11 +733,13 @@ public sealed class ShadowObjectRegistry
{ {
uint lbPrefix = landblockId & 0xFFFF0000u; uint lbPrefix = landblockId & 0xFFFF0000u;
var toRemove = new List<uint>(); var toRemove = new List<uint>();
var touchedOwners = new HashSet<uint>();
foreach (var (entityId, cells) in _entityToCells) foreach (var (entityId, cells) in _entityToCells)
{ {
if (!cells.Exists(cell => (cell & 0xFFFF0000u) == lbPrefix)) if (!cells.Exists(cell => (cell & 0xFFFF0000u) == lbPrefix))
continue; continue;
touchedOwners.Add(entityId);
if (!_withdrawnPrefixesByOwner.TryGetValue(entityId, out var withdrawn)) if (!_withdrawnPrefixesByOwner.TryGetValue(entityId, out var withdrawn))
{ {
withdrawn = new HashSet<uint>(); withdrawn = new HashSet<uint>();
@ -753,6 +780,8 @@ public sealed class ShadowObjectRegistry
_withdrawnPrefixesByOwner.Remove(eid); _withdrawnPrefixesByOwner.Remove(eid);
} }
} }
foreach (uint entityId in touchedOwners)
BumpOwnerVersion(entityId);
} }
/// <summary> /// <summary>
@ -795,6 +824,315 @@ 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;
/// <summary>
/// Copies the committed registry into an off-side collision generation.
/// All mutable lists and sets are cloned; immutable registration and shape
/// payloads may be shared.
/// </summary>
internal void CopyCollisionStateFrom(
ShadowObjectRegistry source,
PhysicsDataCache stagingCache)
{
ArgumentNullException.ThrowIfNull(source);
ArgumentNullException.ThrowIfNull(stagingCache);
Clear();
DataCache = stagingCache;
foreach ((uint cellId, List<ShadowEntry> entries) in source._cells)
_cells[cellId] = new List<ShadowEntry>(entries);
foreach ((uint ownerId, List<uint> cells) in source._entityToCells)
_entityToCells[ownerId] = new List<uint>(cells);
foreach (uint ownerId in source._suspendedEntities)
_suspendedEntities.Add(ownerId);
foreach ((uint ownerId, HashSet<uint> prefixes) in
source._withdrawnPrefixesByOwner)
{
_withdrawnPrefixesByOwner[ownerId] = new HashSet<uint>(prefixes);
}
foreach ((uint ownerId, IReadOnlyList<ShadowShape> shapes) in
source._entityShapes)
{
_entityShapes[ownerId] = shapes;
}
foreach ((uint ownerId, RegistrationRecord registration) in
source._entityReg)
{
_entityReg[ownerId] = registration;
}
foreach ((uint ownerId, ulong version) in source._ownerVersions)
_ownerVersions[ownerId] = version;
}
internal uint[] CaptureDynamicRefloodOwnersForLandblock(
uint landblockId)
{
uint[] owners = CaptureRefloodOwnersForLandblock(landblockId);
return owners.Where(ownerId =>
_entityReg.TryGetValue(ownerId, out RegistrationRecord? record)
&& !record.IsStatic)
.ToArray();
}
/// <summary>
/// Refreshes one staging owner from the exact active payload, then floods
/// it against the staging generation's complete cell graph. The returned
/// source version is the commit-time freshness token.
/// </summary>
internal bool RefreshDynamicOwnerFrom(
ShadowObjectRegistry source,
uint entityId,
uint landblockId,
out ulong sourceVersion)
{
ArgumentNullException.ThrowIfNull(source);
Deregister(entityId);
sourceVersion = source.GetOwnerVersion(entityId);
if (!source._entityReg.TryGetValue(
entityId,
out RegistrationRecord? registration)
|| registration.IsStatic
|| source._suspendedEntities.Contains(entityId)
|| !source.OwnerTouchesLandblock(entityId, landblockId))
{
return false;
}
if (registration.IsMultiPart
&& source._entityShapes.TryGetValue(
entityId,
out IReadOnlyList<ShadowShape>? shapes))
{
RegisterMultiPart(
entityId,
registration.EntityWorldPos,
registration.EntityWorldRot,
shapes,
registration.State,
registration.Flags,
0f,
0f,
landblockId,
registration.SeedCellId,
isStatic: false);
}
else
{
Register(
entityId,
registration.GfxObjId,
registration.EntityWorldPos,
registration.EntityWorldRot,
registration.Radius,
0f,
0f,
landblockId,
registration.CollisionType,
registration.CylHeight,
registration.Scale,
registration.State,
registration.Flags,
registration.SeedCellId,
isStatic: false);
}
return true;
}
internal uint[] FindDirtyDynamicOwners(
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,
uint landblockId,
IReadOnlyDictionary<uint, ulong> expectedDynamicVersions)
{
ArgumentNullException.ThrowIfNull(staging);
uint[] dirty = FindDirtyDynamicOwners(
landblockId,
expectedDynamicVersions);
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(
PreparedLandblockShadowReplacement replacement)
{
if (!ValidateLandblockReplacement(replacement))
{
throw new InvalidOperationException(
"Dynamic shadow owners changed before collision generation commit.");
}
foreach (uint ownerId in replacement.OwnerIds)
Deregister(ownerId);
foreach (PreparedShadowOwnerState state in replacement.OwnerStates)
InstallOwnerState(state);
}
private bool OwnerTouchesLandblock(uint entityId, uint landblockId)
{
uint prefix = landblockId & 0xFFFF0000u;
if (!_entityReg.TryGetValue(entityId, out RegistrationRecord? record))
return false;
if ((record.SeedCellId & 0xFFFF0000u) == prefix)
return true;
if (_entityToCells.TryGetValue(entityId, out List<uint>? cells)
&& cells.Exists(cell => (cell & 0xFFFF0000u) == prefix))
{
return true;
}
return _withdrawnPrefixesByOwner.TryGetValue(
entityId,
out HashSet<uint>? withdrawn)
&& withdrawn.Contains(prefix);
}
private bool TryCaptureOwnerState(
uint entityId,
out PreparedShadowOwnerState? state)
{
if (!_entityReg.TryGetValue(entityId, out RegistrationRecord? registration))
{
state = null;
return false;
}
_entityToCells.TryGetValue(entityId, out List<uint>? cells);
_entityShapes.TryGetValue(
entityId,
out IReadOnlyList<ShadowShape>? shapes);
_withdrawnPrefixesByOwner.TryGetValue(
entityId,
out HashSet<uint>? withdrawn);
var rows = new List<PreparedShadowCellRows>();
if (cells is not null)
{
foreach (uint cellId in cells)
{
if (_cells.TryGetValue(cellId, out List<ShadowEntry>? entries))
{
rows.Add(new PreparedShadowCellRows(
cellId,
entries.Where(entry => entry.EntityId == entityId)
.ToArray()));
}
}
}
state = new PreparedShadowOwnerState(
entityId,
registration,
shapes,
cells?.ToArray() ?? Array.Empty<uint>(),
rows.ToArray(),
_suspendedEntities.Contains(entityId),
withdrawn?.ToArray() ?? Array.Empty<uint>());
return true;
}
private void InstallOwnerState(PreparedShadowOwnerState state)
{
_entityReg[state.EntityId] = state.Registration;
if (state.Shapes is not null)
_entityShapes[state.EntityId] = state.Shapes;
if (state.Suspended)
_suspendedEntities.Add(state.EntityId);
if (state.WithdrawnPrefixes.Length != 0)
{
_withdrawnPrefixesByOwner[state.EntityId] =
new HashSet<uint>(state.WithdrawnPrefixes);
}
if (state.CellIds.Length != 0)
_entityToCells[state.EntityId] = new List<uint>(state.CellIds);
foreach (PreparedShadowCellRows row in state.Rows)
{
foreach (ShadowEntry entry in row.Entries)
AddEntryToCell(entry, row.CellId);
}
BumpOwnerVersion(state.EntityId);
}
internal sealed class PreparedLandblockShadowReplacement
{
internal PreparedLandblockShadowReplacement(
uint landblockPrefix,
uint[] ownerIds,
PreparedShadowOwnerState[] ownerStates,
Dictionary<uint, ulong> dynamicVersions)
{
LandblockPrefix = landblockPrefix;
OwnerIds = ownerIds;
OwnerStates = ownerStates;
DynamicVersions = dynamicVersions;
}
internal uint LandblockPrefix { get; }
internal uint[] OwnerIds { get; }
internal PreparedShadowOwnerState[] OwnerStates { get; }
internal Dictionary<uint, ulong> DynamicVersions { get; }
}
internal sealed record PreparedShadowOwnerState(
uint EntityId,
RegistrationRecord Registration,
IReadOnlyList<ShadowShape>? Shapes,
uint[] CellIds,
PreparedShadowCellRows[] Rows,
bool Suspended,
uint[] WithdrawnPrefixes);
internal sealed record PreparedShadowCellRows(
uint CellId,
ShadowEntry[] Entries);
/// <summary> /// <summary>
/// Retires the complete logical registry at terminal physics-engine /// Retires the complete logical registry at terminal physics-engine
/// disposal, including suspended live registrations that own no cell row. /// disposal, including suspended live registrations that own no cell row.
@ -807,6 +1145,7 @@ public sealed class ShadowObjectRegistry
_withdrawnPrefixesByOwner.Clear(); _withdrawnPrefixesByOwner.Clear();
_entityShapes.Clear(); _entityShapes.Clear();
_entityReg.Clear(); _entityReg.Clear();
_ownerVersions.Clear();
_fallback = null; _fallback = null;
} }

View file

@ -125,4 +125,76 @@ public sealed class CellGraph
return stab; return stab;
return null; return null;
} }
/// <summary>
/// Creates an immutable-reference snapshot for collision-generation
/// preparation. EnvCell and TerrainSurface records are immutable after
/// publication, so copying the registries is sufficient; the active graph
/// remains untouched while the staging graph is rebuilt.
/// </summary>
internal CellGraph CreateCollisionStagingCopy()
{
var copy = new CellGraph { CurrCell = CurrCell };
foreach ((uint id, EnvCell cell) in _envCells)
copy._envCells.TryAdd(id, cell);
foreach ((uint id, (TerrainSurface Terrain, Vector3 Origin) terrain) in
_terrain)
{
copy._terrain.TryAdd(id, terrain);
}
return copy;
}
internal PreparedCellGraphLandblock PrepareLandblockReplacement(
uint 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(
PreparedCellGraphLandblock replacement)
{
uint currentCellId = CurrCell?.Id ?? 0u;
RemoveLandblock(replacement.LandblockPrefix);
if (replacement.HasTerrain)
{
_terrain[replacement.LandblockPrefix] = (
replacement.Terrain!,
replacement.Origin);
}
foreach ((uint id, EnvCell cell) in replacement.EnvCells)
_envCells[id] = cell;
uint desiredCurrentCellId =
(currentCellId & 0xFFFF0000u) == replacement.LandblockPrefix
? currentCellId
: currentCellId == 0u
&& (replacement.CurrentCellId & 0xFFFF0000u)
== replacement.LandblockPrefix
? replacement.CurrentCellId
: 0u;
if (desiredCurrentCellId != 0u)
CurrCell = GetVisible(desiredCurrentCellId);
}
} }
internal sealed record PreparedCellGraphLandblock(
uint LandblockPrefix,
KeyValuePair<uint, EnvCell>[] EnvCells,
bool HasTerrain,
TerrainSurface? Terrain,
Vector3 Origin,
uint CurrentCellId);

View file

@ -174,7 +174,14 @@ internal sealed class HeadlessCollisionNeighborhood
RuntimePhysicsState physics = RuntimePhysicsState physics =
_runtime.EntityObjects.Physics; _runtime.EntityObjects.Physics;
PhysicsDataCache cache = physics.DataCache; RuntimeCollisionAdmission admission =
physics.BeginCollisionAdmission(landblockId);
using PreparedLandblockCollisionGeneration prepared =
physics.PrepareCollisionGeneration(admission);
prepared.SetAssetClosure(
[.. collisions.GfxObjIds],
[.. collisions.SetupIds]);
PhysicsDataCache cache = prepared.DataCache;
TerrainSurface terrain = TerrainSurface terrain =
LandblockPhysicsContentBuilder.BuildTerrainSurface( LandblockPhysicsContentBuilder.BuildTerrainSurface(
landblock, landblock,
@ -197,12 +204,11 @@ internal sealed class HeadlessCollisionNeighborhood
cache, cache,
collisions); collisions);
RuntimeCollisionAdmission admission =
physics.BeginCollisionAdmission(landblockId);
try try
{ {
physics.AdmitCollisionAssets( physics.StageCollisionAssets(
admission, admission,
prepared,
new RuntimeLandblockCollisionAssets( new RuntimeLandblockCollisionAssets(
landblockId, landblockId,
terrain, terrain,
@ -213,12 +219,27 @@ internal sealed class HeadlessCollisionNeighborhood
currentCellId)); currentCellId));
_ = LandblockPhysicsContentBuilder _ = LandblockPhysicsContentBuilder
.PublishStaticCollision( .PublishStaticCollision(
physics.Engine, prepared.Engine,
cache, cache,
landblock, landblock,
collisions, collisions,
origin); origin);
_ = physics.CompleteCollisionAdmission(admission); foreach (uint ownerId in physics.CaptureCollisionDynamicOwners(
admission,
prepared))
{
physics.RefreshCollisionDynamicOwner(
admission,
prepared,
ownerId);
}
RuntimeCollisionGenerationCommit commit =
physics.CommitCollisionGeneration(admission, prepared);
if (!commit.Committed)
{
throw new InvalidOperationException(
"Headless collision generation changed during synchronous publication.");
}
_resident.Add(CanonicalLandblock(landblockId)); _resident.Add(CanonicalLandblock(landblockId));
} }
catch catch

View file

@ -12,6 +12,8 @@
<InternalsVisibleTo Include="AcDream.App" /> <InternalsVisibleTo Include="AcDream.App" />
<InternalsVisibleTo Include="AcDream.App.Tests" /> <InternalsVisibleTo Include="AcDream.App.Tests" />
<InternalsVisibleTo Include="AcDream.Core.Tests" /> <InternalsVisibleTo Include="AcDream.Core.Tests" />
<InternalsVisibleTo Include="acdream-headless" />
<InternalsVisibleTo Include="AcDream.Headless.Tests" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\AcDream.Core\AcDream.Core.csproj" /> <ProjectReference Include="..\AcDream.Core\AcDream.Core.csproj" />

View file

@ -54,7 +54,7 @@ public sealed class RuntimeCollisionAdmission
} }
internal RuntimePhysicsState Owner { get; } internal RuntimePhysicsState Owner { get; }
internal bool AssetsAdmitted { get; set; } internal bool AssetsPrepared { get; set; }
internal bool Completed { get; set; } internal bool Completed { get; set; }
public uint LandblockId { get; } public uint LandblockId { get; }
public ulong Generation { get; } public ulong Generation { get; }
@ -63,7 +63,109 @@ public sealed class RuntimeCollisionAdmission
public readonly record struct RuntimeCollisionAcknowledgement( public readonly record struct RuntimeCollisionAcknowledgement(
uint LandblockId, uint LandblockId,
ulong Generation, ulong Generation,
bool WasResident); bool WasResident,
bool Ready);
public readonly record struct RuntimeCollisionGenerationCommit(
RuntimeCollisionAcknowledgement Acknowledgement,
uint[] DirtyDynamicOwnerIds)
{
public bool Committed => Acknowledgement.Ready;
}
public readonly record struct RuntimeCollisionGenerationCommitted(
uint LandblockId,
ulong Generation,
bool Ready);
/// <summary>
/// One off-side collision generation. It owns a private cache, cell graph,
/// engine, and shadow registry cloned from the previous complete generation.
/// Hosts may populate it incrementally, but only Runtime can activate it.
/// </summary>
internal sealed class PreparedLandblockCollisionGeneration : IDisposable
{
private readonly RuntimePhysicsState _owner;
private readonly RuntimeCollisionAdmission _admission;
private readonly Dictionary<uint, ulong> _dynamicOwnerVersions = new();
private bool _disposed;
internal PreparedLandblockCollisionGeneration(
RuntimePhysicsState owner,
RuntimeCollisionAdmission admission,
PhysicsDataCache dataCache,
PhysicsEngine engine)
{
_owner = owner;
_admission = admission;
DataCache = dataCache;
Engine = engine;
}
internal PhysicsDataCache DataCache { get; }
internal PhysicsEngine Engine { get; }
internal uint[] GfxObjectIds { get; private set; } = Array.Empty<uint>();
internal uint[] SetupIds { get; private set; } = Array.Empty<uint>();
internal IReadOnlyDictionary<uint, ulong> DynamicOwnerVersions =>
_dynamicOwnerVersions;
internal bool IsDisposed => _disposed;
internal bool Matches(
RuntimePhysicsState owner,
RuntimeCollisionAdmission admission) =>
ReferenceEquals(_owner, owner)
&& ReferenceEquals(_admission, admission);
internal void SetAssetClosure(uint[] gfxObjectIds, uint[] setupIds)
{
EnsureUsable();
GfxObjectIds = gfxObjectIds ?? throw new ArgumentNullException(nameof(gfxObjectIds));
SetupIds = setupIds ?? throw new ArgumentNullException(nameof(setupIds));
}
internal void RefreshDynamicOwner(uint ownerId)
{
EnsureUsable();
bool retained = Engine.ShadowObjects.RefreshDynamicOwnerFrom(
_owner.Engine.ShadowObjects,
ownerId,
_admission.LandblockId,
out ulong version);
if (retained)
_dynamicOwnerVersions[ownerId] = version;
else
_dynamicOwnerVersions.Remove(ownerId);
}
internal uint[] FindDirtyDynamicOwners()
{
EnsureUsable();
return _owner.Engine.ShadowObjects.FindDirtyDynamicOwners(
_admission.LandblockId,
_dynamicOwnerVersions);
}
internal void MarkCommitted()
{
EnsureUsable();
_disposed = true;
}
public void Dispose()
{
if (_disposed)
return;
Engine.Clear();
_dynamicOwnerVersions.Clear();
_disposed = true;
}
private void EnsureUsable()
{
if (_disposed)
throw new ObjectDisposedException(nameof(PreparedLandblockCollisionGeneration));
}
}
/// <summary> /// <summary>
/// Presentation-free mutable physics world for one Runtime/session owner. /// Presentation-free mutable physics world for one Runtime/session owner.
@ -83,9 +185,12 @@ public sealed class RuntimePhysicsState : IDisposable
private readonly Dictionary<uint, ulong> _collisionGenerations = new(); private readonly Dictionary<uint, ulong> _collisionGenerations = new();
private readonly Dictionary<uint, RuntimeCollisionAdmission> private readonly Dictionary<uint, RuntimeCollisionAdmission>
_collisionAdmissions = new(); _collisionAdmissions = new();
private int _collisionMutationThreadId;
private bool _disposed; private bool _disposed;
public event Action<RuntimePhysicsCellCommit>? CellCommitted; public event Action<RuntimePhysicsCellCommit>? CellCommitted;
public event Action<RuntimeCollisionGenerationCommitted>?
CollisionGenerationCommitted;
internal RuntimePhysicsState( internal RuntimePhysicsState(
RuntimeEntityDirectory entities, RuntimeEntityDirectory entities,
@ -865,6 +970,7 @@ public sealed class RuntimePhysicsState : IDisposable
uint landblockId) uint landblockId)
{ {
EnsureNotDisposed(); EnsureNotDisposed();
EnsureCollisionMutationThread();
uint canonical = CanonicalLandblock(landblockId); uint canonical = CanonicalLandblock(landblockId);
ulong generation = _collisionGenerations.TryGetValue( ulong generation = _collisionGenerations.TryGetValue(
canonical, canonical,
@ -880,11 +986,29 @@ public sealed class RuntimePhysicsState : IDisposable
return admission; return admission;
} }
public void AdmitCollisionAssets( internal PreparedLandblockCollisionGeneration PrepareCollisionGeneration(
RuntimeCollisionAdmission admission)
{
ValidateAdmission(admission);
EnsureCollisionMutationThread();
PhysicsDataCache stagingCache = DataCache.CreateCollisionStagingCopy();
PhysicsEngine stagingEngine =
Engine.CreateCollisionStagingCopy(stagingCache);
return new PreparedLandblockCollisionGeneration(
this,
admission,
stagingCache,
stagingEngine);
}
internal void StageCollisionAssets(
RuntimeCollisionAdmission admission, RuntimeCollisionAdmission admission,
PreparedLandblockCollisionGeneration prepared,
RuntimeLandblockCollisionAssets assets) RuntimeLandblockCollisionAssets assets)
{ {
ValidateAdmission(admission); ValidateAdmission(admission);
EnsureCollisionMutationThread();
ValidatePreparedGeneration(admission, prepared);
ArgumentNullException.ThrowIfNull(assets); ArgumentNullException.ThrowIfNull(assets);
if (CanonicalLandblock(assets.LandblockId) if (CanonicalLandblock(assets.LandblockId)
!= admission.LandblockId) != admission.LandblockId)
@ -898,13 +1022,13 @@ public sealed class RuntimePhysicsState : IDisposable
throw new InvalidOperationException( throw new InvalidOperationException(
"A completed collision admission cannot publish more assets."); "A completed collision admission cannot publish more assets.");
} }
if (admission.AssetsAdmitted) if (admission.AssetsPrepared)
{ {
throw new InvalidOperationException( throw new InvalidOperationException(
"Collision assets were already admitted by this receipt."); "Collision assets were already prepared by this receipt.");
} }
Engine.AddLandblock( prepared.Engine.AddLandblock(
admission.LandblockId, admission.LandblockId,
assets.Terrain, assets.Terrain,
assets.CellSurfaces, assets.CellSurfaces,
@ -914,38 +1038,106 @@ public sealed class RuntimePhysicsState : IDisposable
if ((assets.CurrentCellId & 0xFFFF0000u) if ((assets.CurrentCellId & 0xFFFF0000u)
== (admission.LandblockId & 0xFFFF0000u)) == (admission.LandblockId & 0xFFFF0000u))
{ {
Engine.UpdatePlayerCurrCell(assets.CurrentCellId); prepared.Engine.UpdatePlayerCurrCell(assets.CurrentCellId);
} }
admission.AssetsAdmitted = true; admission.AssetsPrepared = true;
} }
public RuntimeCollisionAcknowledgement CompleteCollisionAdmission( internal uint[] CaptureCollisionDynamicOwners(
RuntimeCollisionAdmission admission) RuntimeCollisionAdmission admission,
PreparedLandblockCollisionGeneration prepared)
{ {
ValidateAdmission(admission); ValidateAdmission(admission);
if (!admission.AssetsAdmitted) EnsureCollisionMutationThread();
ValidatePreparedGeneration(admission, prepared);
return Engine.ShadowObjects.CaptureDynamicRefloodOwnersForLandblock(
admission.LandblockId);
}
internal void RefreshCollisionDynamicOwner(
RuntimeCollisionAdmission admission,
PreparedLandblockCollisionGeneration prepared,
uint ownerId)
{
ValidateAdmission(admission);
EnsureCollisionMutationThread();
ValidatePreparedGeneration(admission, prepared);
prepared.RefreshDynamicOwner(ownerId);
}
internal RuntimeCollisionGenerationCommit CommitCollisionGeneration(
RuntimeCollisionAdmission admission,
PreparedLandblockCollisionGeneration prepared)
{
ValidateAdmission(admission);
EnsureCollisionMutationThread();
ValidatePreparedGeneration(admission, prepared);
if (!admission.AssetsPrepared)
{ {
throw new InvalidOperationException( throw new InvalidOperationException(
"Collision admission cannot complete before its assets publish."); "Collision generation cannot commit before its assets are prepared.");
} }
if (admission.Completed) if (admission.Completed)
{ {
throw new InvalidOperationException( throw new InvalidOperationException(
"Collision admission has already completed."); "Collision generation has already completed.");
} }
uint[] dirtyOwners = prepared.FindDirtyDynamicOwners();
if (dirtyOwners.Length != 0)
{
return new RuntimeCollisionGenerationCommit(
new RuntimeCollisionAcknowledgement(
admission.LandblockId,
admission.Generation,
Engine.IsLandblockTerrainResident(admission.LandblockId),
Ready: false),
dirtyOwners);
}
PhysicsEngine.PreparedPhysicsEngineLandblock replacement =
Engine.PrepareLandblockReplacement(
prepared.Engine,
admission.LandblockId,
prepared.GfxObjectIds,
prepared.SetupIds,
prepared.DynamicOwnerVersions);
if (!Engine.ValidateLandblockReplacement(replacement))
{
dirtyOwners = prepared.FindDirtyDynamicOwners();
return new RuntimeCollisionGenerationCommit(
new RuntimeCollisionAcknowledgement(
admission.LandblockId,
admission.Generation,
Engine.IsLandblockTerrainResident(admission.LandblockId),
Ready: false),
dirtyOwners);
}
Engine.CommitLandblockReplacement(replacement);
admission.Completed = true; admission.Completed = true;
_collisionAdmissions.Remove(admission.LandblockId); _collisionAdmissions.Remove(admission.LandblockId);
return new RuntimeCollisionAcknowledgement( prepared.MarkCommitted();
var acknowledgement = new RuntimeCollisionAcknowledgement(
admission.LandblockId, admission.LandblockId,
admission.Generation, admission.Generation,
Engine.IsLandblockTerrainResident(admission.LandblockId)); Engine.IsLandblockTerrainResident(admission.LandblockId),
Ready: Engine.IsLandblockTerrainResident(admission.LandblockId));
PublishCollisionGenerationCommitted(
new RuntimeCollisionGenerationCommitted(
acknowledgement.LandblockId,
acknowledgement.Generation,
acknowledgement.Ready));
return new RuntimeCollisionGenerationCommit(
acknowledgement,
Array.Empty<uint>());
} }
public RuntimeCollisionAcknowledgement DemoteCollisionToTerrain( public RuntimeCollisionAcknowledgement DemoteCollisionToTerrain(
uint landblockId) uint landblockId)
{ {
EnsureNotDisposed(); EnsureNotDisposed();
EnsureCollisionMutationThread();
uint canonical = CanonicalLandblock(landblockId); uint canonical = CanonicalLandblock(landblockId);
bool resident = Engine.IsLandblockTerrainResident(canonical); bool resident = Engine.IsLandblockTerrainResident(canonical);
InvalidateCollisionAdmission(canonical); InvalidateCollisionAdmission(canonical);
@ -953,13 +1145,15 @@ public sealed class RuntimePhysicsState : IDisposable
return new RuntimeCollisionAcknowledgement( return new RuntimeCollisionAcknowledgement(
canonical, canonical,
_collisionGenerations[canonical], _collisionGenerations[canonical],
resident); resident,
Ready: Engine.IsLandblockTerrainResident(canonical));
} }
public RuntimeCollisionAcknowledgement WithdrawCollision( public RuntimeCollisionAcknowledgement WithdrawCollision(
uint landblockId) uint landblockId)
{ {
EnsureNotDisposed(); EnsureNotDisposed();
EnsureCollisionMutationThread();
uint canonical = CanonicalLandblock(landblockId); uint canonical = CanonicalLandblock(landblockId);
bool resident = Engine.IsLandblockTerrainResident(canonical); bool resident = Engine.IsLandblockTerrainResident(canonical);
InvalidateCollisionAdmission(canonical); InvalidateCollisionAdmission(canonical);
@ -967,7 +1161,8 @@ public sealed class RuntimePhysicsState : IDisposable
return new RuntimeCollisionAcknowledgement( return new RuntimeCollisionAcknowledgement(
canonical, canonical,
_collisionGenerations[canonical], _collisionGenerations[canonical],
resident); resident,
Ready: false);
} }
public void Dispose() public void Dispose()
@ -981,6 +1176,7 @@ public sealed class RuntimePhysicsState : IDisposable
_collisionAdmissions.Clear(); _collisionAdmissions.Clear();
_collisionGenerations.Clear(); _collisionGenerations.Clear();
CellCommitted = null; CellCommitted = null;
CollisionGenerationCommitted = null;
_disposed = true; _disposed = true;
} }
@ -1053,6 +1249,20 @@ public sealed class RuntimePhysicsState : IDisposable
private void EnsureNotDisposed() => private void EnsureNotDisposed() =>
ObjectDisposedException.ThrowIf(_disposed, this); ObjectDisposedException.ThrowIf(_disposed, this);
private void EnsureCollisionMutationThread()
{
int current = Environment.CurrentManagedThreadId;
int owner = Interlocked.CompareExchange(
ref _collisionMutationThreadId,
current,
0);
if (owner != 0 && owner != current)
{
throw new InvalidOperationException(
"Collision generations must be staged and committed on one update thread.");
}
}
private void EnsureCurrent(RuntimeEntityRecord record) private void EnsureCurrent(RuntimeEntityRecord record)
{ {
if (!Entities.IsCurrent(record)) if (!Entities.IsCurrent(record))
@ -1081,6 +1291,39 @@ public sealed class RuntimePhysicsState : IDisposable
} }
} }
private void ValidatePreparedGeneration(
RuntimeCollisionAdmission admission,
PreparedLandblockCollisionGeneration prepared)
{
ArgumentNullException.ThrowIfNull(prepared);
ObjectDisposedException.ThrowIf(prepared.IsDisposed, prepared);
if (!prepared.Matches(this, admission))
{
throw new InvalidOperationException(
"Prepared collision generation is stale or belongs to another admission.");
}
}
private void PublishCollisionGenerationCommitted(
RuntimeCollisionGenerationCommitted committed)
{
Delegate[] observers = CollisionGenerationCommitted?
.GetInvocationList() ?? Array.Empty<Delegate>();
foreach (Delegate observer in observers)
{
try
{
((Action<RuntimeCollisionGenerationCommitted>)observer)(committed);
}
catch (Exception error)
{
System.Diagnostics.Trace.TraceError(
"Collision-generation commit observer failed after activation: {0}",
error);
}
}
}
private void InvalidateCollisionAdmission(uint landblockId) private void InvalidateCollisionAdmission(uint landblockId)
{ {
ulong generation = _collisionGenerations.TryGetValue( ulong generation = _collisionGenerations.TryGetValue(

View file

@ -119,7 +119,11 @@ public sealed class RuntimePhysicsOwnershipTests
collisionPublisher, collisionPublisher,
StringComparison.Ordinal); StringComparison.Ordinal);
Assert.Contains( Assert.Contains(
"AdmitCollisionAssets(", "StageCollisionAssets(",
collisionPublisher,
StringComparison.Ordinal);
Assert.Contains(
"CommitCollisionGeneration(",
collisionPublisher, collisionPublisher,
StringComparison.Ordinal); StringComparison.Ordinal);
Assert.DoesNotContain( Assert.DoesNotContain(

View file

@ -88,6 +88,11 @@ public sealed class LandblockPhysicsPublisherTests
Assert.Equal(1, diagnostics.CellSurfaceCount); Assert.Equal(1, diagnostics.CellSurfaceCount);
Assert.Equal(1, diagnostics.PortalPlaneCount); Assert.Equal(1, diagnostics.PortalPlaneCount);
Assert.Equal(1, diagnostics.BuildingCount); Assert.Equal(1, diagnostics.BuildingCount);
Assert.Null(fixture.Cache.CellGraph.GetVisible(envCellId));
Assert.Empty(fixture.Cache.BuildingIds);
fixture.Publisher.CompletePublication(receipt);
Assert.NotNull(fixture.Cache.CellGraph.GetVisible(envCellId)); Assert.NotNull(fixture.Cache.CellGraph.GetVisible(envCellId));
BuildingPhysics building = Assert.Single( BuildingPhysics building = Assert.Single(
fixture.Cache.BuildingIds.Select(id => fixture.Cache.GetBuilding(id)!)); fixture.Cache.BuildingIds.Select(id => fixture.Cache.GetBuilding(id)!));
@ -502,6 +507,11 @@ public sealed class LandblockPhysicsPublisherTests
LandblockPhysicsPublication secondReceipt = Begin( LandblockPhysicsPublication secondReceipt = Begin(
fixture.Publisher, fixture.Publisher,
Build(FirstLandblock, [moved])); Build(FirstLandblock, [moved]));
ShadowEntry duringPreparation = Assert.Single(
fixture.Engine.ShadowObjects.AllEntriesForDebug());
Assert.Equal(
first.Position + new Vector3(0f, 0f, 0.6f),
duringPreparation.Position);
fixture.Publisher.CompletePublication(secondReceipt); fixture.Publisher.CompletePublication(secondReceipt);
Assert.Equal(1, fixture.Engine.LandblockCount); Assert.Equal(1, fixture.Engine.LandblockCount);
@ -513,6 +523,27 @@ public sealed class LandblockPhysicsPublisherTests
Assert.Equal(2, fixture.Publisher.Diagnostics.CompleteCount); Assert.Equal(2, fixture.Publisher.Diagnostics.CompleteCount);
} }
[Fact]
public void CancelledPublicationDisposesOnlyItsPrivateCollisionGeneration()
{
var fixture = Fixture();
Publish(fixture.Publisher, Build(FirstLandblock));
Assert.True(fixture.Engine.IsLandblockTerrainResident(FirstLandblock));
LandblockPhysicsPublication pending = Begin(
fixture.Publisher,
Build(AdjacentLandblock));
Assert.False(pending.PreparedGeneration.IsDisposed);
pending.Dispose();
Assert.True(pending.PreparedGeneration.IsDisposed);
Assert.True(fixture.Engine.IsLandblockTerrainResident(FirstLandblock));
Assert.False(fixture.Engine.IsLandblockTerrainResident(AdjacentLandblock));
Assert.Throws<ObjectDisposedException>(() =>
fixture.Publisher.CompletePublication(pending));
}
[Fact] [Fact]
public void NearReapply_RemovesOmittedStaticAcrossSeamAndPreservesNeighborOwner() public void NearReapply_RemovesOmittedStaticAcrossSeamAndPreservesNeighborOwner()
{ {
@ -760,6 +791,17 @@ public sealed class LandblockPhysicsPublisherTests
Assert.DoesNotContain("ShadowObjects.RefloodLandblock", source, StringComparison.Ordinal); Assert.DoesNotContain("ShadowObjects.RefloodLandblock", source, StringComparison.Ordinal);
Assert.DoesNotContain("_physicsEngine.DemoteLandblockToTerrain", source, StringComparison.Ordinal); Assert.DoesNotContain("_physicsEngine.DemoteLandblockToTerrain", source, StringComparison.Ordinal);
Assert.DoesNotContain("_physicsEngine.RemoveLandblock", source, StringComparison.Ordinal); Assert.DoesNotContain("_physicsEngine.RemoveLandblock", source, StringComparison.Ordinal);
string publisherSource = File.ReadAllText(Path.Combine(
FindRepoRoot(),
"src",
"AcDream.App",
"Streaming",
"LandblockPhysicsPublisher.cs"));
Assert.DoesNotContain("_physicsDataCache.RemoveCellsForLandblock", publisherSource, StringComparison.Ordinal);
Assert.DoesNotContain("_physicsDataCache.RemoveBuildingsForLandblock", publisherSource, StringComparison.Ordinal);
Assert.DoesNotContain("_physicsEngine.ShadowObjects.Reflood", publisherSource, StringComparison.Ordinal);
Assert.Contains("CommitCollisionGeneration(", publisherSource, StringComparison.Ordinal);
} }
private static void Publish( private static void Publish(

View file

@ -311,27 +311,28 @@ public sealed class RuntimePhysicsStateTests
first.Physics.BeginCollisionAdmission(0xA9B4FFFFu); first.Physics.BeginCollisionAdmission(0xA9B4FFFFu);
Assert.Throws<InvalidOperationException>(() => Assert.Throws<InvalidOperationException>(() =>
first.Physics.AdmitCollisionAssets( first.Physics.PrepareCollisionGeneration(admission));
admission,
CollisionAssets(0xA9B4FFFFu)));
Assert.Throws<InvalidOperationException>(() => Assert.Throws<InvalidOperationException>(() =>
second.Physics.AdmitCollisionAssets( second.Physics.PrepareCollisionGeneration(newer));
newer,
CollisionAssets(0xA9B4FFFFu)));
first.Physics.AdmitCollisionAssets( using PreparedLandblockCollisionGeneration prepared =
first.Physics.PrepareCollisionGeneration(newer);
first.Physics.StageCollisionAssets(
newer, newer,
prepared,
CollisionAssets(0xA9B4FFFFu)); CollisionAssets(0xA9B4FFFFu));
RuntimeCollisionAcknowledgement completed = RuntimeCollisionGenerationCommit commit =
first.Physics.CompleteCollisionAdmission(newer); first.Physics.CommitCollisionGeneration(newer, prepared);
RuntimeCollisionAcknowledgement completed = commit.Acknowledgement;
Assert.True(commit.Committed);
Assert.True(completed.WasResident); Assert.True(completed.WasResident);
Assert.Equal(1, first.Physics.Engine.LandblockCount); Assert.Equal(1, first.Physics.Engine.LandblockCount);
Assert.Equal( Assert.Equal(
0, 0,
first.Physics.CaptureOwnership().CollisionAdmissionCount); first.Physics.CaptureOwnership().CollisionAdmissionCount);
Assert.Throws<InvalidOperationException>(() => Assert.Throws<InvalidOperationException>(() =>
first.Physics.CompleteCollisionAdmission(newer)); first.Physics.CommitCollisionGeneration(newer, prepared));
RuntimeCollisionAcknowledgement withdrawn = RuntimeCollisionAcknowledgement withdrawn =
first.Physics.WithdrawCollision(0xA9B4FFFFu); first.Physics.WithdrawCollision(0xA9B4FFFFu);
@ -340,6 +341,193 @@ public sealed class RuntimePhysicsStateTests
Assert.Equal(0, first.Physics.Engine.LandblockCount); Assert.Equal(0, first.Physics.Engine.LandblockCount);
} }
[Fact]
public void CollisionGenerationKeepsPreviousWorldVisibleUntilOneCommitNotification()
{
using var lifetime = new RuntimeEntityObjectLifetime();
RuntimePhysicsState physics = lifetime.Physics;
RuntimeCollisionAdmission firstAdmission =
physics.BeginCollisionAdmission(0xA9B4FFFFu);
using (PreparedLandblockCollisionGeneration first =
physics.PrepareCollisionGeneration(firstAdmission))
{
physics.StageCollisionAssets(
firstAdmission,
first,
CollisionAssets(0xA9B4FFFFu, terrainHeight: 10f));
Assert.True(physics.CommitCollisionGeneration(
firstAdmission,
first).Committed);
}
int notifications = 0;
physics.CollisionGenerationCommitted += _ => notifications++;
RuntimeCollisionAdmission replacementAdmission =
physics.BeginCollisionAdmission(0xA9B4FFFFu);
using PreparedLandblockCollisionGeneration replacement =
physics.PrepareCollisionGeneration(replacementAdmission);
physics.StageCollisionAssets(
replacementAdmission,
replacement,
CollisionAssets(0xA9B4FFFFu, terrainHeight: 25f));
Assert.Equal(10f, physics.Engine.SampleTerrainZ(1f, 1f));
Assert.Equal(0, notifications);
RuntimeCollisionGenerationCommit committed =
physics.CommitCollisionGeneration(
replacementAdmission,
replacement);
Assert.True(committed.Committed);
Assert.Equal(25f, physics.Engine.SampleTerrainZ(1f, 1f));
Assert.Equal(1, notifications);
}
[Fact]
public void CollisionGenerationRejectsStaleReplacementWithoutMutatingActiveWorld()
{
using var lifetime = new RuntimeEntityObjectLifetime();
RuntimePhysicsState physics = lifetime.Physics;
RuntimeCollisionAdmission stale =
physics.BeginCollisionAdmission(0xA9B4FFFFu);
using PreparedLandblockCollisionGeneration preparedStale =
physics.PrepareCollisionGeneration(stale);
physics.StageCollisionAssets(
stale,
preparedStale,
CollisionAssets(0xA9B4FFFFu, terrainHeight: 10f));
RuntimeCollisionAdmission current =
physics.BeginCollisionAdmission(0xA9B4FFFFu);
Assert.Throws<InvalidOperationException>(() =>
physics.CommitCollisionGeneration(stale, preparedStale));
Assert.False(physics.Engine.IsLandblockTerrainResident(0xA9B4FFFFu));
using PreparedLandblockCollisionGeneration preparedCurrent =
physics.PrepareCollisionGeneration(current);
physics.StageCollisionAssets(
current,
preparedCurrent,
CollisionAssets(0xA9B4FFFFu, terrainHeight: 20f));
Assert.True(physics.CommitCollisionGeneration(
current,
preparedCurrent).Committed);
Assert.Equal(20f, physics.Engine.SampleTerrainZ(1f, 1f));
}
[Fact]
public void MovingDynamicOwnerDuringStagingMustRefreshBeforeCommit()
{
using var lifetime = new RuntimeEntityObjectLifetime();
RuntimePhysicsState physics = lifetime.Physics;
RuntimeCollisionAdmission initialAdmission =
physics.BeginCollisionAdmission(0x0101FFFFu);
using (PreparedLandblockCollisionGeneration initial =
physics.PrepareCollisionGeneration(initialAdmission))
{
physics.StageCollisionAssets(
initialAdmission,
initial,
CollisionAssets(0x0101FFFFu, terrainHeight: 5f));
Assert.True(physics.CommitCollisionGeneration(
initialAdmission,
initial).Committed);
}
physics.Engine.ShadowObjects.Register(
42u,
0x01000001u,
new Vector3(10f, 10f, 0f),
Quaternion.Identity,
0.5f,
0f,
0f,
0x0101FFFFu,
seedCellId: 0x01010001u,
isStatic: false);
RuntimeCollisionAdmission admission =
physics.BeginCollisionAdmission(0x0101FFFFu);
using PreparedLandblockCollisionGeneration prepared =
physics.PrepareCollisionGeneration(admission);
physics.StageCollisionAssets(
admission,
prepared,
CollisionAssets(0x0101FFFFu, terrainHeight: 15f));
uint owner = Assert.Single(physics.CaptureCollisionDynamicOwners(
admission,
prepared));
physics.RefreshCollisionDynamicOwner(admission, prepared, owner);
physics.Engine.ShadowObjects.UpdatePosition(
owner,
new Vector3(30f, 10f, 0f),
Quaternion.Identity,
0f,
0f,
0x0101FFFFu,
seedCellId: 0x01010009u);
RuntimeCollisionGenerationCommit rejected =
physics.CommitCollisionGeneration(admission, prepared);
Assert.False(rejected.Committed);
Assert.Equal(owner, Assert.Single(rejected.DirtyDynamicOwnerIds));
Assert.Equal(5f, physics.Engine.SampleTerrainZ(1f, 1f));
physics.RefreshCollisionDynamicOwner(admission, prepared, owner);
Assert.True(physics.CommitCollisionGeneration(
admission,
prepared).Committed);
Assert.Equal(15f, physics.Engine.SampleTerrainZ(1f, 1f));
Assert.Contains(
physics.Engine.ShadowObjects.GetObjectsInCell(0x01010009u),
entry => entry.EntityId == owner);
}
[Fact]
public void SpawnAndDeleteDuringStagingAreBothGenerationGated()
{
using var lifetime = new RuntimeEntityObjectLifetime();
RuntimePhysicsState physics = lifetime.Physics;
RuntimeCollisionAdmission admission =
physics.BeginCollisionAdmission(0x0101FFFFu);
using PreparedLandblockCollisionGeneration prepared =
physics.PrepareCollisionGeneration(admission);
physics.StageCollisionAssets(
admission,
prepared,
CollisionAssets(0x0101FFFFu, terrainHeight: 12f));
Assert.Empty(physics.CaptureCollisionDynamicOwners(admission, prepared));
physics.Engine.ShadowObjects.Register(
77u,
0x01000001u,
new Vector3(10f, 10f, 0f),
Quaternion.Identity,
0.5f,
0f,
0f,
0x0101FFFFu,
seedCellId: 0x01010001u,
isStatic: false);
RuntimeCollisionGenerationCommit spawned =
physics.CommitCollisionGeneration(admission, prepared);
Assert.False(spawned.Committed);
Assert.Equal(77u, Assert.Single(spawned.DirtyDynamicOwnerIds));
physics.RefreshCollisionDynamicOwner(admission, prepared, 77u);
physics.Engine.ShadowObjects.Deregister(77u);
RuntimeCollisionGenerationCommit deleted =
physics.CommitCollisionGeneration(admission, prepared);
Assert.False(deleted.Committed);
Assert.Equal(77u, Assert.Single(deleted.DirtyDynamicOwnerIds));
physics.RefreshCollisionDynamicOwner(admission, prepared, 77u);
Assert.True(physics.CommitCollisionGeneration(
admission,
prepared).Committed);
Assert.Empty(physics.Engine.ShadowObjects.AllEntriesForDebug());
}
[Fact] [Fact]
public void TerminalDisposalClearsLandblocksShadowsAndWorksets() public void TerminalDisposalClearsLandblocksShadowsAndWorksets()
{ {
@ -352,10 +540,13 @@ public sealed class RuntimePhysicsStateTests
RuntimeCollisionAdmission admission = RuntimeCollisionAdmission admission =
lifetime.Physics.BeginCollisionAdmission(0x0101FFFFu); lifetime.Physics.BeginCollisionAdmission(0x0101FFFFu);
lifetime.Physics.AdmitCollisionAssets( using PreparedLandblockCollisionGeneration prepared =
lifetime.Physics.PrepareCollisionGeneration(admission);
lifetime.Physics.StageCollisionAssets(
admission, admission,
prepared,
CollisionAssets(0x0101FFFFu)); CollisionAssets(0x0101FFFFu));
_ = lifetime.Physics.CompleteCollisionAdmission(admission); _ = lifetime.Physics.CommitCollisionGeneration(admission, prepared);
lifetime.Physics.Engine.ShadowObjects.Register( lifetime.Physics.Engine.ShadowObjects.Register(
entityId: record.LocalEntityId!.Value, entityId: record.LocalEntityId!.Value,
gfxObjId: 0x01000001u, gfxObjId: 0x01000001u,
@ -740,10 +931,12 @@ public sealed class RuntimePhysicsStateTests
} }
private static RuntimeLandblockCollisionAssets CollisionAssets( private static RuntimeLandblockCollisionAssets CollisionAssets(
uint landblockId) uint landblockId,
float terrainHeight = 0f)
{ {
var heights = new byte[81]; var heights = new byte[81];
var table = new float[256]; var table = new float[256];
table[0] = terrainHeight;
return new RuntimeLandblockCollisionAssets( return new RuntimeLandblockCollisionAssets(
landblockId, landblockId,
new TerrainSurface(heights, table), new TerrainSurface(heights, table),