fix(physics): seal collision generations before activation

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

View file

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

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-2 | Async readiness gates replace retail's synchronous destination cell load. **#229 refinement (2026-07-20):** login and F751 portal-space exit now share `WorldRevealReadinessBarrier`, so neither path can expose the normal viewport until the same render-publication, composite-texture, and collision domains converge. A hydratable indoor claim requires its owning Near-tier static/EnvCell mesh set, destination composites, and exact EnvCell physics (`IsSpawnCellReady`); an outdoor claim requires those render domains plus terrain/collision residency for the required Near ring. Hard-recenter generations and tier-aware completion application prevent stale overlapping loads/unloads or Far/Near jobs from opening or erasing the gate; mesh upload remains separate from balanced landblock ownership. Claims beyond NumCells still take the loud unhydratable-placement path. `RuntimeWorldTransitState` owns the shared reveal generation, accepted readiness, transit correlation, and exact generation/cell-scoped host-acknowledgement suffix. `WorldRevealCoordinator` is a graphical adapter holding only App resource receipts; normalized Runtime checkpoints observe ownership without defining another readiness path. **Slice E3 refinement (2026-07-24):** the same generation now publishes an immediate `WorldGenerationQuiescence` edge: old-world drawing/spatial queries, simulation/effect clocks, reconciliation, targeting, and 3-D audio stop while retained physical teardown advances through metered cursors and destination network/UI/streaming/readiness remain live. **Slice E4 refinement (2026-07-24):** accepted render/physics/static publication may span update frames through retained exact cursors, but reveal still consumes only the completed spatial/render-ready generation; building and EnvCell snapshots remain invisible until complete and the final spatial identity swap stays observer-atomic. **Slice E5 refinement (2026-07-24):** the reveal generation owns one exact destination reservation across every typed budget dimension. Stale completion cannot consume or clear its replacement, and hydratable incomplete content is never force-revealed; portal transit retains the DAT tunnel and centered retail wait cue until readiness converges. The hold→materialize→regain-control lifecycle remains owned by `TeleportAnimSequencer`. | `src/AcDream.Runtime/World/RuntimeWorldTransitState.cs`; `src/AcDream.App/Streaming/WorldRevealCoordinator.cs`; `src/AcDream.App/Streaming/WorldGenerationQuiescence.cs`; `src/AcDream.App/Streaming/WorldRevealReadinessBarrier.cs`; `src/AcDream.App/Streaming/StreamingOriginRecenterCoordinator.cs`; `src/AcDream.App/Streaming/LandblockPresentationPipeline.cs`; `src/AcDream.App/Streaming/StreamingController.cs`; `src/AcDream.App/Rendering/PortalTunnelPresentation.cs`; `src/AcDream.App/UI/PortalWaitNoticeController.cs`; `src/AcDream.App/Streaming/GpuWorldState.cs` (`IsRenderReady`); `src/AcDream.App/Rendering/Wb/LandblockSpawnAdapter.cs`; `src/AcDream.Core/Physics/PhysicsEngine.cs` (`IsSpawnCellReady`, `IsNeighborhoodTerrainResident`) | This is the asynchronous equivalent of retail leaving `SmartBox::position_update_complete` false while `CellManager::blocking_for_cells` is set: neither initial login nor portal arrival may reveal or continue simulating an old/partial collision world, a terrain-only Far shell, or a published-but-not-drawable GPU landblock. Indoor does not require a terrain heightmap, only the owning render landblock and exact EnvCell. | Gate opens early → grey/untextured first login or portal reveal, free-fall, wrong-cell rooting, missing scenery, or a still-active old generation; predicate never satisfies (streamer/DAT/upload failure) → login remains behind the world render gate, while portal transit remains in the authored tunnel and presents the centered wait cue after five seconds. | `SmartBox::UseTime` 0x00455410; `gmSmartBoxUI::UseTime` 0x004D6E30; `gmSmartBoxUI::EndTeleportAnimation` 0x004D65A0 |
| AD-5 | Outdoor `point_in_cell` is an identity compare against the global XY-column cell from `LandDefs.AdjustToOutside` (no per-cell containment test) | `src/AcDream.Core/Physics/CellTransit.cs:865` | Landcells are disjoint 24 m columns — identity-compare against the column under the sphere centre is exactly equivalent to retail's per-candidate test | If block-origin/lcoord math is wrong at a landblock seam, the compare silently never matches — outdoor membership freezes at boundaries (the pre-#106 symptom) | `find_cell_list` pick pc:308788-308825; `CLandCell::point_in_cell` (get_block_offset pc:308804) |
| ~~AD-6~~ | **RETIRED 2026-07-31 (placement/streaming Slice 3B).** Cell/cache/topology/building/static-shadow publication plus retained dynamic-owner `recalc_cross_cells` is one Runtime-owned collision generation. App and Headless build only an off-side `PreparedLandblockCollisionGeneration`; the complete previous generation remains queryable until the one update-thread commit. Every affected dynamic owner carries an exact mutation version. Movement, spawn, or deletion during staging rejects activation and returns only dirty owners to a bounded refresh cursor; a stale admission/recenter/cancellation can dispose only its private generation. The commit installs the precomputed cell rows before its single notification/readiness acknowledgement, so no optional hydration callback can omit reflood and no observer sees mixed old/new cells. | `src/AcDream.Runtime/Physics/RuntimePhysicsState.cs` (`PrepareCollisionGeneration`, `CommitCollisionGeneration`); `src/AcDream.Core/Physics/PhysicsDataCache.cs`; `PhysicsEngine.cs`; `ShadowObjectRegistry.cs`; `src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs`; `src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs`; `tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsStateTests.cs`; `tests/AcDream.App.Tests/Streaming/LandblockPhysicsPublisherTests.cs` | — | — | `CObjCell::init_objects``CPhysicsObj::recalc_cross_cells`, 0x0052b420 / 0x00515a30; `CPhysicsObj::SetPositionInternal` shadow replacement tail 0x00515330 |
| ~~AD-6~~ | **RETIRED 2026-07-31 (placement/streaming Slice 3B).** Cell/cache/topology/building/static-shadow publication plus every retained non-suspended owner touching or withdrawn from the prefix is one Runtime-owned collision generation. Retained includes dynamics and adjacent-root statics; only target-root statics are superseded by the authored replacement. App and Headless build off-side through mutation-stable, one-work-unit capture/seal cursors; rowless owner state/payload updates, movement, spawn, deletion, and seam-static changes reject stale sealing. The complete previous generation remains queryable until one allocation-free update-thread activation. A stale admission or staging failure disposes only that private generation and cannot withdraw the active world or invalidate a newer admission. The commit installs precomputed cell rows and clears repaired withdrawal markers before its single notification/readiness acknowledgement, so no optional hydration callback can omit reflood and no observer sees mixed old/new cells. | `src/AcDream.Runtime/Physics/RuntimePhysicsState.cs` (`PrepareCollisionGeneration`, `AdvanceCollisionGenerationSeal`, `CommitCollisionGeneration`); `src/AcDream.Core/Physics/PhysicsDataCache.cs`; `PhysicsEngine.cs`; `ShadowObjectRegistry.cs`; `src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs`; `src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs`; `tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsStateTests.cs`; `tests/AcDream.App.Tests/Streaming/LandblockPhysicsPublisherTests.cs`; `tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs` | — | — | `CObjCell::init_objects``CPhysicsObj::recalc_cross_cells`, 0x0052b420 / 0x00515a30; `CPhysicsObj::SetPositionInternal` shadow replacement tail 0x00515330 |
| AD-10 | Remote slope projection relocated to the queue-empty/head-reached combiner boundary; retail projects inside `CTransition::adjust_offset` during the sweep | `src/AcDream.Core/Physics/PositionManager.cs:47` | Remote bodies don't run a full local transition sweep; boundary projection removes the ~5 Hz Z staircase on slopes, no-op on flat ground | The single-point terrain-normal sample can differ from the sweep's contact plane (cell boundaries, props underfoot) — remote Z drift / stair-stepping | `CTransition::adjust_offset` pc:272296-272346 |
| ~~AD-11~~ | **RETIRED 2026-07-23** — the matching binary disproved the old nonzero interpretation: `ItemUses::IsUseable` executes `not bitfield; and eax,1`, so absent/reset zero is usable and only `USEABLE_NO` disables use. Toolbar, item policy, and world interaction now share that exact Core predicate. | `src/AcDream.Core/Items/ClientObject.cs` (`ItemUseability.IsUseable`); `src/AcDream.Core/Items/ItemInteractionPolicy.cs`; `src/AcDream.App/Interaction/WorldSelectionQuery.cs` | — | — | `ItemUses::IsUseable @ 0x004FCCC0`; matching v11.4186 instructions recorded in `docs/research/2026-07-23-retail-item-use-and-autowear-pseudocode.md` |
| AD-12 | SecondaryAttributeTable coefficients hardcoded (Health=End×0.5, Stam=End×1.0, Mana=Self×1.0) instead of dat-read; unknown attributes contribute 0 | `src/AcDream.Core/Player/LocalPlayerState.cs:279` | Coefficients never vary across retail dat versions; re-confirmed by ACE AttributeFormula.cs + holtburger; dat port can replace later | A customized portal.dat with modified vital formulas silently yields wrong max-vitals; a missing attribute snapshot underestimates max | SecondaryAttributeTable portal.dat 0x0E0..0x0E2; `CreatureVital::GetMaxValue` 0x0058F2DD |

View file

@ -25,19 +25,25 @@ The asynchronous unit is now one Runtime-owned collision generation:
GfxObj/Setup catalogs are not copied; the accepted build's exact closure is
populated by the existing cursors.
3. App and Headless publish terrain, EnvCells, topology, buildings, prepared
collision assets, static owners, and retained dynamic-owner cell sets only
into that private generation.
4. Each dynamic owner refresh captures its exact `ShadowObjectRegistry`
mutation version. Movement, state/payload change, spawn, suspension, or
deletion changes that version.
5. `CommitCollisionGeneration` validates the admission, exact affected-owner
set, and all captured versions. If any owner is dirty, it returns the sorted
dirty IDs without touching the active world; App advances those IDs through
its existing work meter and retries.
6. Once fresh, Runtime precomputes the replacement arrays and synchronously
replaces the landblock's cache/graph/engine/building/static and dynamic
shadow state on the same update thread. Only after the complete replacement
does it emit `CollisionGenerationCommitted` and a ready acknowledgement.
collision assets, and target-root static owners only into that private
generation.
4. A mutation-stable cursor captures every non-suspended owner that touches —
or has a withdrawn repair marker for — the target prefix. That includes
live dynamic owners and statics rooted in an adjacent landblock. Only a
target-root static is omitted, because the authored replacement supersedes
it. Each retained owner refresh captures its exact
`ShadowObjectRegistry` mutation version; a rowless withdrawn owner therefore
remains freshness-gated when state or payload changes.
5. Explicit one-work-unit cursors build the complete replacement before the
activation frame: requested global collision records, cells/topology,
buildings, cell graph removals, affected static owners, retained-owner
states, and removal lists. An active-owner mutation restarts capture and
sealing without touching the active world.
6. `CommitCollisionGeneration` performs only the final mutation-version check
and installs the already sealed replacement synchronously on the update
thread. The dense 256-owner gate measures zero managed bytes in this final
activation. Only after the complete replacement does Runtime emit
`CollisionGenerationCommitted` and a ready acknowledgement.
The stable borrowed `PhysicsEngine` and `PhysicsDataCache` object identities do
not change. Presentation and no-window hosts use the same Runtime transaction.
@ -47,6 +53,9 @@ shadow state.
## Failure and lifetime rules
- A newer admission invalidates an older prepared generation.
- Cancellation names one admission and its private staging generation. It can
never withdraw or demote the active landblock, and cancelling a stale receipt
cannot invalidate a newer admission.
- Demotion, withdrawal, reset, and disposal invalidate the admission before
changing the active generation.
- Disposing a stale/cancelled prepared generation clears only its private
@ -64,7 +73,15 @@ The focused Runtime/App tests pin:
- stale admission replacement has no active-world side effect;
- movement during staging rejects, refreshes only the dirty owner, and then
installs its latest cell set;
- an authoritative state change on a retained rowless owner rejects a stale
seal and installs the refreshed state on retry;
- a neighboring static whose shadow crossed the seam is restored atomically
on reload and its withdrawn-prefix marker clears only at activation;
- spawn and deletion during staging both reject stale activation;
- Headless faults immediately after admission and after staging preserve the
prior complete world and leave no collision admission behind;
- dense sealing consumes at most one work unit per call, while its final
256-owner activation allocates zero managed bytes;
- graphical and no-window publishers use the same Runtime transaction;
- removal and terminal teardown converge the active ownership ledger.

View file

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

View file

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

View file

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

View file

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

View file

@ -182,12 +182,12 @@ public sealed class PhysicsEngine
return staging;
}
internal PreparedPhysicsEngineLandblock PrepareLandblockReplacement(
internal LandblockReplacementBuilder CreateLandblockReplacementBuilder(
PhysicsEngine staging,
uint landblockId,
ReadOnlySpan<uint> gfxObjectIds,
ReadOnlySpan<uint> setupIds,
IReadOnlyDictionary<uint, ulong> expectedDynamicVersions)
uint[] gfxObjectIds,
uint[] setupIds,
IReadOnlyDictionary<uint, ulong> expectedRetainedVersions)
{
ArgumentNullException.ThrowIfNull(staging);
uint canonical = (landblockId & 0xFFFF0000u) | 0xFFFFu;
@ -201,31 +201,25 @@ public sealed class PhysicsEngine
PhysicsDataCache stagingCache = staging.DataCache
?? throw new InvalidOperationException(
"Staging collision engine has no data cache.");
return new PreparedPhysicsEngineLandblock(
return new LandblockReplacementBuilder(
canonical,
landblock,
stagingCache.PrepareLandblockReplacement(
(DataCache ?? throw new InvalidOperationException(
"Active collision engine has no data cache."))
.CreateLandblockReplacementBuilder(
stagingCache,
canonical,
gfxObjectIds,
setupIds),
ShadowObjects.PrepareLandblockReplacement(
ShadowObjects.CreateLandblockReplacementBuilder(
staging.ShadowObjects,
canonical,
expectedDynamicVersions));
expectedRetainedVersions));
}
internal bool ValidateLandblockReplacement(
PreparedPhysicsEngineLandblock replacement) =>
ShadowObjects.ValidateLandblockReplacement(replacement.Shadows);
internal void CommitLandblockReplacement(
PreparedPhysicsEngineLandblock replacement)
{
if (!ValidateLandblockReplacement(replacement))
{
throw new InvalidOperationException(
"Collision generation changed after it was sealed.");
}
(DataCache ?? throw new InvalidOperationException(
"Active collision engine has no data cache."))
.CommitLandblockReplacement(replacement.DataCache);
@ -253,6 +247,64 @@ public sealed class PhysicsEngine
internal ShadowObjectRegistry.PreparedLandblockShadowReplacement Shadows { get; }
}
internal sealed class LandblockReplacementBuilder : IDisposable
{
private readonly uint _landblockId;
private readonly LandblockPhysics _landblock;
private readonly PhysicsDataCache.LandblockReplacementBuilder _data;
private readonly ShadowObjectRegistry.LandblockReplacementBuilder _shadows;
private int _phase;
internal LandblockReplacementBuilder(
uint landblockId,
LandblockPhysics landblock,
PhysicsDataCache.LandblockReplacementBuilder data,
ShadowObjectRegistry.LandblockReplacementBuilder shadows)
{
_landblockId = landblockId;
_landblock = landblock;
_data = data;
_shadows = shadows;
}
internal int WorkUnits => _data.WorkUnits + _shadows.WorkUnits;
internal bool IsStable => _shadows.IsStable;
internal PreparedPhysicsEngineLandblock? Prepared { get; private set; }
internal bool Advance()
{
if (_phase == 0)
{
if (!_data.Advance())
return false;
_phase++;
return false;
}
if (_phase == 1)
{
if (!_shadows.Advance())
return false;
if (IsStable && _data.Prepared is not null
&& _shadows.Prepared is not null)
{
Prepared = new PreparedPhysicsEngineLandblock(
_landblockId,
_landblock,
_data.Prepared,
_shadows.Prepared);
}
_phase++;
}
return true;
}
public void Dispose()
{
_data.Dispose();
_shadows.Dispose();
}
}
/// <summary>
/// Register a landblock with its terrain surface, indoor cells, portal
/// planes, and world-space origin offset.

View file

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

View file

@ -19,7 +19,7 @@ namespace AcDream.Core.World.Cells;
public sealed class CellGraph
{
private readonly ConcurrentDictionary<uint, EnvCell> _envCells = new();
private readonly ConcurrentDictionary<uint, (TerrainSurface Terrain, Vector3 Origin)> _terrain = new();
private readonly ConcurrentDictionary<uint, CellGraphTerrain> _terrain = new();
/// <summary>The player's current cell — the render/lighting root. Written ONLY at the
/// player chokepoint <see cref="AcDream.Core.Physics.PhysicsEngine.UpdatePlayerCurrCell"/>
@ -34,7 +34,8 @@ public sealed class CellGraph
/// <param name="landblockPrefix">Any id in the cell's landblock; masked to (id &amp; 0xFFFF0000).</param>
public void RegisterTerrain(uint landblockPrefix, TerrainSurface terrain, Vector3 worldOrigin)
=> _terrain[landblockPrefix & 0xFFFF0000u] = (terrain, worldOrigin);
=> _terrain[landblockPrefix & 0xFFFF0000u] =
new CellGraphTerrain(terrain, worldOrigin);
/// <summary>
/// World origin (SW corner) of the landblock containing <paramref name="id"/>,
@ -137,46 +138,36 @@ public sealed class CellGraph
var copy = new CellGraph { CurrCell = CurrCell };
foreach ((uint id, EnvCell cell) in _envCells)
copy._envCells.TryAdd(id, cell);
foreach ((uint id, (TerrainSurface Terrain, Vector3 Origin) terrain) in
_terrain)
foreach ((uint id, CellGraphTerrain terrain) in _terrain)
{
copy._terrain.TryAdd(id, terrain);
}
return copy;
}
internal PreparedCellGraphLandblock PrepareLandblockReplacement(
uint landblockId)
{
uint prefix = landblockId & 0xFFFF0000u;
KeyValuePair<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 LandblockReplacementBuilder CreateLandblockReplacementBuilder(
CellGraph staging,
uint landblockId) => new(this, staging, landblockId);
internal void CommitLandblockReplacement(
PreparedCellGraphLandblock replacement)
{
uint currentCellId = CurrCell?.Id ?? 0u;
RemoveLandblock(replacement.LandblockPrefix);
for (int index = 0; index < replacement.EnvCellIdsToRemove.Count; index++)
_envCells.TryRemove(replacement.EnvCellIdsToRemove[index], out _);
if (replacement.HasTerrain)
{
_terrain[replacement.LandblockPrefix] = (
replacement.Terrain!,
replacement.Origin);
_terrain[replacement.LandblockPrefix] = replacement.Terrain!;
}
foreach ((uint id, EnvCell cell) in replacement.EnvCells)
else
{
_terrain.TryRemove(replacement.LandblockPrefix, out _);
}
for (int index = 0; index < replacement.EnvCells.Count; index++)
{
(uint id, EnvCell cell) = replacement.EnvCells[index];
_envCells[id] = cell;
}
uint desiredCurrentCellId =
(currentCellId & 0xFFFF0000u) == replacement.LandblockPrefix
@ -188,13 +179,101 @@ public sealed class CellGraph
: 0u;
if (desiredCurrentCellId != 0u)
CurrCell = GetVisible(desiredCurrentCellId);
else if ((currentCellId & 0xFFFF0000u)
== replacement.LandblockPrefix)
CurrCell = null;
}
internal sealed class LandblockReplacementBuilder : IDisposable
{
private readonly CellGraph _active;
private readonly CellGraph _staging;
private readonly uint _prefix;
private readonly List<KeyValuePair<uint, EnvCell>> _envCells = new();
private readonly HashSet<uint> _stagingIds = new();
private readonly List<uint> _removeIds = new();
private IEnumerator<KeyValuePair<uint, EnvCell>>? _enumerator;
private int _phase;
internal LandblockReplacementBuilder(
CellGraph active,
CellGraph staging,
uint landblockId)
{
_active = active;
_staging = staging;
_prefix = landblockId & 0xFFFF0000u;
_enumerator = staging._envCells.GetEnumerator();
}
internal bool Advance()
{
if (_phase == 0)
{
if (_enumerator!.MoveNext())
{
KeyValuePair<uint, EnvCell> pair = _enumerator.Current;
if ((pair.Key & 0xFFFF0000u) == _prefix
&& (pair.Key & 0xFFFFu) >= 0x0100u)
{
_envCells.Add(pair);
_stagingIds.Add(pair.Key);
}
return false;
}
_enumerator.Dispose();
_enumerator = _active._envCells.GetEnumerator();
_phase = 1;
return false;
}
if (_phase == 1)
{
if (_enumerator!.MoveNext())
{
uint id = _enumerator.Current.Key;
if ((id & 0xFFFF0000u) == _prefix
&& (id & 0xFFFFu) >= 0x0100u
&& !_stagingIds.Contains(id))
{
_removeIds.Add(id);
}
return false;
}
_enumerator.Dispose();
_enumerator = null;
bool hasTerrain = _staging._terrain.TryGetValue(
_prefix,
out var terrain);
Prepared = new PreparedCellGraphLandblock(
_prefix,
_removeIds,
_envCells,
hasTerrain,
terrain,
_staging.CurrCell?.Id ?? 0u);
_phase = 2;
}
return true;
}
internal PreparedCellGraphLandblock? Prepared { get; private set; }
public void Dispose()
{
_enumerator?.Dispose();
_enumerator = null;
}
}
}
internal sealed record PreparedCellGraphLandblock(
uint LandblockPrefix,
KeyValuePair<uint, EnvCell>[] EnvCells,
IReadOnlyList<uint> EnvCellIdsToRemove,
IReadOnlyList<KeyValuePair<uint, EnvCell>> EnvCells,
bool HasTerrain,
TerrainSurface? Terrain,
Vector3 Origin,
CellGraphTerrain? Terrain,
uint CurrentCellId);
internal sealed record CellGraphTerrain(
TerrainSurface Terrain,
Vector3 Origin);

View file

@ -18,6 +18,77 @@ internal interface IHeadlessCollisionNeighborhood
bool IsReady(uint fullCellId);
}
internal static class HeadlessCollisionGenerationTransaction
{
internal static RuntimeCollisionGenerationCommit Execute(
RuntimePhysicsState physics,
uint landblockId,
Action<RuntimeCollisionAdmission>? afterAdmission,
Action<RuntimeCollisionAdmission,
PreparedLandblockCollisionGeneration> stage)
{
ArgumentNullException.ThrowIfNull(physics);
ArgumentNullException.ThrowIfNull(stage);
RuntimeCollisionAdmission admission =
physics.BeginCollisionAdmission(landblockId);
PreparedLandblockCollisionGeneration? prepared = null;
bool committed = false;
try
{
// This hook exists so the exact post-admission/pre-prepare failure
// boundary remains covered. Production does not install one.
afterAdmission?.Invoke(admission);
prepared = physics.PrepareCollisionGeneration(admission);
stage(admission, prepared);
RuntimeCollisionOwnerCaptureStep ownerCapture;
do
{
ownerCapture = physics.AdvanceCollisionRetainedOwnerCapture(
admission,
prepared);
}
while (!ownerCapture.Completed);
foreach (uint ownerId in prepared.RetainedOwnerIds)
{
physics.RefreshCollisionRetainedOwner(
admission,
prepared,
ownerId);
}
RuntimeCollisionSealStep seal;
do
{
seal = physics.AdvanceCollisionGenerationSeal(
admission,
prepared);
}
while (!seal.Completed && !seal.Restarted);
if (!seal.Completed)
{
throw new InvalidOperationException(
"Headless collision owner set changed during synchronous sealing.");
}
RuntimeCollisionGenerationCommit result =
physics.CommitCollisionGeneration(admission, prepared);
if (!result.Committed)
{
throw new InvalidOperationException(
"Headless collision generation changed during synchronous publication.");
}
committed = true;
return result;
}
finally
{
if (!committed)
physics.CancelCollisionGeneration(admission, prepared);
}
}
}
/// <summary>
/// Per-session mutable collision publication over process-shared immutable
/// DAT and pak inputs. Every session retains its own engine, data cache,
@ -172,81 +243,58 @@ internal sealed class HeadlessCollisionNeighborhood
_content.PreparedCollision,
landblock);
RuntimePhysicsState physics =
_runtime.EntityObjects.Physics;
RuntimeCollisionAdmission admission =
physics.BeginCollisionAdmission(landblockId);
using PreparedLandblockCollisionGeneration prepared =
physics.PrepareCollisionGeneration(admission);
prepared.SetAssetClosure(
[.. collisions.GfxObjIds],
[.. collisions.SetupIds]);
PhysicsDataCache cache = prepared.DataCache;
TerrainSurface terrain =
LandblockPhysicsContentBuilder.BuildTerrainSurface(
landblock,
_content.HeightTable.AsSpan());
var cellSurfaces = new List<CellSurface>();
var portalPlanes = new List<PortalPlane>();
LandblockPhysicsContentBuilder.PublishPreparedCells(
cache,
landblock,
collisions,
origin,
cellSurfaces,
portalPlanes);
LandblockPhysicsContentBuilder.CacheBuildings(
cache,
landblock,
terrain,
origin);
LandblockPhysicsContentBuilder.CachePreparedObjects(
cache,
collisions);
try
{
physics.StageCollisionAssets(
admission,
prepared,
new RuntimeLandblockCollisionAssets(
landblockId,
terrain,
cellSurfaces,
portalPlanes,
origin.X,
origin.Y,
currentCellId));
_ = LandblockPhysicsContentBuilder
.PublishStaticCollision(
prepared.Engine,
RuntimePhysicsState physics = _runtime.EntityObjects.Physics;
_ = HeadlessCollisionGenerationTransaction.Execute(
physics,
landblockId,
afterAdmission: null,
(admission, prepared) =>
{
prepared.SetAssetClosure(
[.. collisions.GfxObjIds],
[.. collisions.SetupIds]);
PhysicsDataCache cache = prepared.DataCache;
TerrainSurface terrain =
LandblockPhysicsContentBuilder.BuildTerrainSurface(
landblock,
_content.HeightTable.AsSpan());
var cellSurfaces = new List<CellSurface>();
var portalPlanes = new List<PortalPlane>();
LandblockPhysicsContentBuilder.PublishPreparedCells(
cache,
landblock,
collisions,
origin,
cellSurfaces,
portalPlanes);
LandblockPhysicsContentBuilder.CacheBuildings(
cache,
landblock,
terrain,
origin);
foreach (uint ownerId in physics.CaptureCollisionDynamicOwners(
admission,
prepared))
{
physics.RefreshCollisionDynamicOwner(
LandblockPhysicsContentBuilder.CachePreparedObjects(
cache,
collisions);
physics.StageCollisionAssets(
admission,
prepared,
ownerId);
}
RuntimeCollisionGenerationCommit commit =
physics.CommitCollisionGeneration(admission, prepared);
if (!commit.Committed)
{
throw new InvalidOperationException(
"Headless collision generation changed during synchronous publication.");
}
_resident.Add(CanonicalLandblock(landblockId));
}
catch
{
_ = physics.WithdrawCollision(landblockId);
throw;
}
new RuntimeLandblockCollisionAssets(
landblockId,
terrain,
cellSurfaces,
portalPlanes,
origin.X,
origin.Y,
currentCellId));
_ = LandblockPhysicsContentBuilder
.PublishStaticCollision(
prepared.Engine,
cache,
landblock,
collisions,
origin);
});
_resident.Add(CanonicalLandblock(landblockId));
}
private void RetireAll()

View file

@ -68,7 +68,7 @@ public readonly record struct RuntimeCollisionAcknowledgement(
public readonly record struct RuntimeCollisionGenerationCommit(
RuntimeCollisionAcknowledgement Acknowledgement,
uint[] DirtyDynamicOwnerIds)
uint[] DirtyRetainedOwnerIds)
{
public bool Committed => Acknowledgement.Ready;
}
@ -87,7 +87,11 @@ internal sealed class PreparedLandblockCollisionGeneration : IDisposable
{
private readonly RuntimePhysicsState _owner;
private readonly RuntimeCollisionAdmission _admission;
private readonly Dictionary<uint, ulong> _dynamicOwnerVersions = new();
private readonly Dictionary<uint, ulong> _retainedOwnerVersions = new();
private readonly List<uint> _retainedOwnerIds = new();
private ShadowObjectRegistry.RetainedRefloodOwnerScan? _retainedOwnerScan;
private PhysicsEngine.LandblockReplacementBuilder? _sealBuilder;
private PhysicsEngine.PreparedPhysicsEngineLandblock? _sealedReplacement;
private bool _disposed;
internal PreparedLandblockCollisionGeneration(
@ -106,9 +110,13 @@ internal sealed class PreparedLandblockCollisionGeneration : IDisposable
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 IReadOnlyDictionary<uint, ulong> RetainedOwnerVersions =>
_retainedOwnerVersions;
internal bool IsDisposed => _disposed;
internal bool RetainedOwnerCaptureComplete { get; private set; }
internal ulong RetainedOwnerCaptureMutationVersion { get; private set; }
internal bool IsSealed => _sealedReplacement is not null;
internal ulong SealedShadowMutationVersion { get; private set; }
internal bool Matches(
RuntimePhysicsState owner,
@ -123,26 +131,155 @@ internal sealed class PreparedLandblockCollisionGeneration : IDisposable
SetupIds = setupIds ?? throw new ArgumentNullException(nameof(setupIds));
}
internal void RefreshDynamicOwner(uint ownerId)
internal void RefreshRetainedOwner(uint ownerId)
{
EnsureUsable();
bool retained = Engine.ShadowObjects.RefreshDynamicOwnerFrom(
bool retained = Engine.ShadowObjects.RefreshRetainedOwnerFrom(
_owner.Engine.ShadowObjects,
ownerId,
_admission.LandblockId,
out ulong version);
if (retained)
_dynamicOwnerVersions[ownerId] = version;
_retainedOwnerVersions[ownerId] = version;
else
_dynamicOwnerVersions.Remove(ownerId);
_retainedOwnerVersions.Remove(ownerId);
}
internal uint[] FindDirtyDynamicOwners()
internal RuntimeCollisionOwnerCaptureStep AdvanceRetainedOwnerCapture()
{
EnsureUsable();
return _owner.Engine.ShadowObjects.FindDirtyDynamicOwners(
if (RetainedOwnerCaptureComplete)
{
return new RuntimeCollisionOwnerCaptureStep(
Completed: true,
Restarted: false,
HasOwner: false,
OwnerId: 0u);
}
_retainedOwnerScan ??= _owner.Engine.ShadowObjects
.CreateRetainedRefloodOwnerScan(_admission.LandblockId);
ShadowObjectRegistry.RetainedRefloodOwnerScanStep step =
_retainedOwnerScan.Advance();
if (step.Completed && !step.Stable)
{
ResetRetainedOwnerCapture();
return new RuntimeCollisionOwnerCaptureStep(
Completed: false,
Restarted: true,
HasOwner: false,
OwnerId: 0u);
}
if (step.HasOwner)
_retainedOwnerIds.Add(step.OwnerId);
if (step.Completed)
{
_retainedOwnerScan.Dispose();
_retainedOwnerScan = null;
RetainedOwnerCaptureMutationVersion = step.SourceMutationVersion;
RetainedOwnerCaptureComplete = true;
}
return new RuntimeCollisionOwnerCaptureStep(
RetainedOwnerCaptureComplete,
Restarted: false,
step.HasOwner,
step.OwnerId);
}
internal IReadOnlyList<uint> RetainedOwnerIds
{
get
{
EnsureUsable();
if (!RetainedOwnerCaptureComplete)
{
throw new InvalidOperationException(
"Retained collision-owner capture is incomplete.");
}
return _retainedOwnerIds;
}
}
internal void ResetRetainedOwnerCapture()
{
EnsureUsable();
_retainedOwnerScan?.Dispose();
_retainedOwnerScan = null;
_retainedOwnerIds.Clear();
_retainedOwnerVersions.Clear();
RetainedOwnerCaptureComplete = false;
RetainedOwnerCaptureMutationVersion = 0UL;
_sealedReplacement = null;
_sealBuilder?.Dispose();
_sealBuilder = null;
SealedShadowMutationVersion = 0UL;
}
internal RuntimeCollisionSealStep AdvanceSeal()
{
EnsureUsable();
if (!RetainedOwnerCaptureComplete)
{
return new RuntimeCollisionSealStep(
Completed: false,
Restarted: true,
WorkUnits: 0);
}
if (_owner.Engine.ShadowObjects.MutationVersion
!= RetainedOwnerCaptureMutationVersion)
{
ResetRetainedOwnerCapture();
return new RuntimeCollisionSealStep(
Completed: false,
Restarted: true,
WorkUnits: 0);
}
if (_retainedOwnerVersions.Count != _retainedOwnerIds.Count)
{
throw new InvalidOperationException(
"Every retained collision owner must refresh before sealing.");
}
_sealBuilder ??= _owner.Engine.CreateLandblockReplacementBuilder(
Engine,
_admission.LandblockId,
_dynamicOwnerVersions);
GfxObjectIds,
SetupIds,
_retainedOwnerVersions);
int before = _sealBuilder.WorkUnits;
bool completed = _sealBuilder.Advance();
int workUnits = _sealBuilder.WorkUnits - before;
if (!completed)
{
return new RuntimeCollisionSealStep(
Completed: false,
Restarted: false,
workUnits);
}
if (!_sealBuilder.IsStable || _sealBuilder.Prepared is null)
{
ResetRetainedOwnerCapture();
return new RuntimeCollisionSealStep(
Completed: false,
Restarted: true,
workUnits);
}
_sealedReplacement = _sealBuilder.Prepared;
_sealBuilder.Dispose();
_sealBuilder = null;
SealedShadowMutationVersion =
_owner.Engine.ShadowObjects.MutationVersion;
return new RuntimeCollisionSealStep(
Completed: true,
Restarted: false,
workUnits);
}
internal PhysicsEngine.PreparedPhysicsEngineLandblock TakeSealedReplacement()
{
EnsureUsable();
return _sealedReplacement
?? throw new InvalidOperationException(
"Collision generation must be sealed before activation.");
}
internal void MarkCommitted()
@ -156,7 +293,14 @@ internal sealed class PreparedLandblockCollisionGeneration : IDisposable
if (_disposed)
return;
Engine.Clear();
_dynamicOwnerVersions.Clear();
_retainedOwnerScan?.Dispose();
_retainedOwnerScan = null;
_retainedOwnerIds.Clear();
_retainedOwnerVersions.Clear();
_sealBuilder?.Dispose();
_sealBuilder = null;
_sealedReplacement = null;
SealedShadowMutationVersion = 0UL;
_disposed = true;
}
@ -167,6 +311,17 @@ internal sealed class PreparedLandblockCollisionGeneration : IDisposable
}
}
internal readonly record struct RuntimeCollisionOwnerCaptureStep(
bool Completed,
bool Restarted,
bool HasOwner,
uint OwnerId);
internal readonly record struct RuntimeCollisionSealStep(
bool Completed,
bool Restarted,
int WorkUnits);
/// <summary>
/// Presentation-free mutable physics world for one Runtime/session owner.
/// Immutable prepared collision inputs may be supplied by a graphical or
@ -1001,6 +1156,43 @@ public sealed class RuntimePhysicsState : IDisposable
stagingEngine);
}
/// <summary>
/// Cancels only the named unpublished generation. The currently active
/// collision world is never withdrawn. A stale receipt may dispose its
/// own staging storage but cannot invalidate a newer admission.
/// </summary>
internal void CancelCollisionGeneration(
RuntimeCollisionAdmission admission,
PreparedLandblockCollisionGeneration? prepared = null)
{
EnsureNotDisposed();
EnsureCollisionMutationThread();
ArgumentNullException.ThrowIfNull(admission);
if (!ReferenceEquals(admission.Owner, this))
{
throw new ArgumentException(
"Collision admission belongs to another Runtime.",
nameof(admission));
}
if (prepared is not null && !prepared.Matches(this, admission))
{
throw new ArgumentException(
"Prepared collision generation belongs to another admission.",
nameof(prepared));
}
prepared?.Dispose();
if (_collisionAdmissions.TryGetValue(
admission.LandblockId,
out RuntimeCollisionAdmission? current)
&& ReferenceEquals(current, admission))
{
_collisionAdmissions.Remove(admission.LandblockId);
_collisionGenerations[admission.LandblockId] = checked(
admission.Generation + 1UL);
}
}
internal void StageCollisionAssets(
RuntimeCollisionAdmission admission,
PreparedLandblockCollisionGeneration prepared,
@ -1043,18 +1235,17 @@ public sealed class RuntimePhysicsState : IDisposable
admission.AssetsPrepared = true;
}
internal uint[] CaptureCollisionDynamicOwners(
internal RuntimeCollisionOwnerCaptureStep AdvanceCollisionRetainedOwnerCapture(
RuntimeCollisionAdmission admission,
PreparedLandblockCollisionGeneration prepared)
{
ValidateAdmission(admission);
EnsureCollisionMutationThread();
ValidatePreparedGeneration(admission, prepared);
return Engine.ShadowObjects.CaptureDynamicRefloodOwnersForLandblock(
admission.LandblockId);
return prepared.AdvanceRetainedOwnerCapture();
}
internal void RefreshCollisionDynamicOwner(
internal void RefreshCollisionRetainedOwner(
RuntimeCollisionAdmission admission,
PreparedLandblockCollisionGeneration prepared,
uint ownerId)
@ -1062,7 +1253,32 @@ public sealed class RuntimePhysicsState : IDisposable
ValidateAdmission(admission);
EnsureCollisionMutationThread();
ValidatePreparedGeneration(admission, prepared);
prepared.RefreshDynamicOwner(ownerId);
prepared.RefreshRetainedOwner(ownerId);
}
internal void RestartCollisionRetainedOwnerCapture(
RuntimeCollisionAdmission admission,
PreparedLandblockCollisionGeneration prepared)
{
ValidateAdmission(admission);
EnsureCollisionMutationThread();
ValidatePreparedGeneration(admission, prepared);
prepared.ResetRetainedOwnerCapture();
}
internal RuntimeCollisionSealStep AdvanceCollisionGenerationSeal(
RuntimeCollisionAdmission admission,
PreparedLandblockCollisionGeneration prepared)
{
ValidateAdmission(admission);
EnsureCollisionMutationThread();
ValidatePreparedGeneration(admission, prepared);
if (!admission.AssetsPrepared)
{
throw new InvalidOperationException(
"Collision generation cannot seal before its assets are prepared.");
}
return prepared.AdvanceSeal();
}
internal RuntimeCollisionGenerationCommit CommitCollisionGeneration(
@ -1083,38 +1299,25 @@ public sealed class RuntimePhysicsState : IDisposable
"Collision generation has already completed.");
}
uint[] dirtyOwners = prepared.FindDirtyDynamicOwners();
if (dirtyOwners.Length != 0)
if (!prepared.IsSealed)
{
throw new InvalidOperationException(
"Collision generation cannot activate before sealing.");
}
if (Engine.ShadowObjects.MutationVersion
!= prepared.SealedShadowMutationVersion)
{
prepared.ResetRetainedOwnerCapture();
return new RuntimeCollisionGenerationCommit(
new RuntimeCollisionAcknowledgement(
admission.LandblockId,
admission.Generation,
Engine.IsLandblockTerrainResident(admission.LandblockId),
Ready: false),
dirtyOwners);
Array.Empty<uint>());
}
PhysicsEngine.PreparedPhysicsEngineLandblock replacement =
Engine.PrepareLandblockReplacement(
prepared.Engine,
admission.LandblockId,
prepared.GfxObjectIds,
prepared.SetupIds,
prepared.DynamicOwnerVersions);
if (!Engine.ValidateLandblockReplacement(replacement))
{
dirtyOwners = prepared.FindDirtyDynamicOwners();
return new RuntimeCollisionGenerationCommit(
new RuntimeCollisionAcknowledgement(
admission.LandblockId,
admission.Generation,
Engine.IsLandblockTerrainResident(admission.LandblockId),
Ready: false),
dirtyOwners);
}
Engine.CommitLandblockReplacement(replacement);
Engine.CommitLandblockReplacement(prepared.TakeSealedReplacement());
admission.Completed = true;
_collisionAdmissions.Remove(admission.LandblockId);
prepared.MarkCommitted();

View file

@ -976,6 +976,43 @@ public sealed class LandblockPhysicsPublisherTests
new Dictionary<uint, GfxObj>());
}
[Fact]
public void ReloadedSeamAtomicallyRestoresAdjacentStaticAndClearsRepairMarker()
{
var fixture = Fixture();
CacheCylinderSetup(fixture.Cache);
WorldEntity neighbor = CylinderEntity(
0x80AAB401u,
new Vector3(192.25f, 12f, 0f));
Publish(fixture.Publisher, Build(FirstLandblock));
Publish(fixture.Publisher, Build(AdjacentLandblock, [neighbor]));
Assert.True(fixture.Engine.ShadowObjects.HasOwnerRowsInLandblock(
neighbor.Id,
FirstLandblock));
fixture.Publisher.RemoveLandblock(FirstLandblock);
Assert.Equal(1, fixture.Engine.ShadowObjects.WithdrawnPrefixMarkerCount);
Assert.False(fixture.Engine.ShadowObjects.HasOwnerRowsInLandblock(
neighbor.Id,
FirstLandblock));
LandblockPhysicsPublication pending = Begin(
fixture.Publisher,
Build(FirstLandblock));
Assert.Equal(1, fixture.Engine.ShadowObjects.WithdrawnPrefixMarkerCount);
fixture.Publisher.CompletePublication(pending);
Assert.Equal(0, fixture.Engine.ShadowObjects.WithdrawnPrefixMarkerCount);
Assert.True(fixture.Engine.ShadowObjects.HasOwnerRowsInLandblock(
neighbor.Id,
FirstLandblock));
Assert.True(fixture.Engine.ShadowObjects.HasOwnerRowsInLandblock(
neighbor.Id,
AdjacentLandblock));
Assert.Equal(1, fixture.Engine.ShadowObjects.RetainedRegistrationCount);
}
private static LandblockCollisionBuild FlatCellClosure(
PhysicsDatBundle bundle,
uint envCellId)

View file

@ -12,6 +12,7 @@ using AcDream.Headless.Platform;
using AcDream.Runtime;
using AcDream.Runtime.Entities;
using AcDream.Runtime.Gameplay;
using AcDream.Runtime.Physics;
using AcDream.Runtime.Session;
using AcDream.Runtime.World;
@ -373,6 +374,72 @@ public sealed class HeadlessSessionHostTests
Assert.Equal(2, collision.CenterCount);
}
[Fact]
public void CollisionTransactionCancelsPostAdmissionFaultWithoutWithdrawingActiveWorld()
{
using var lifetime = new RuntimeEntityObjectLifetime();
RuntimePhysicsState physics = lifetime.Physics;
const uint landblockId = 0xA9B4FFFFu;
_ = HeadlessCollisionGenerationTransaction.Execute(
physics,
landblockId,
afterAdmission: null,
(admission, prepared) =>
physics.StageCollisionAssets(
admission,
prepared,
CollisionAssets(landblockId, 10f)));
Assert.Throws<FixtureCollisionPublicationException>(() =>
HeadlessCollisionGenerationTransaction.Execute(
physics,
landblockId,
_ => throw new FixtureCollisionPublicationException(),
(_, _) => throw new InvalidOperationException(
"Staging must not run after the injected admission fault.")));
Assert.Equal(10f, physics.Engine.SampleTerrainZ(1f, 1f));
RuntimePhysicsOwnershipSnapshot ownership = physics.CaptureOwnership();
Assert.Equal(1, ownership.LandblockCount);
Assert.Equal(0, ownership.CollisionAdmissionCount);
}
[Fact]
public void CollisionTransactionCancelsStagingFaultWithoutWithdrawingActiveWorld()
{
using var lifetime = new RuntimeEntityObjectLifetime();
RuntimePhysicsState physics = lifetime.Physics;
const uint landblockId = 0xA9B4FFFFu;
_ = HeadlessCollisionGenerationTransaction.Execute(
physics,
landblockId,
afterAdmission: null,
(admission, prepared) =>
physics.StageCollisionAssets(
admission,
prepared,
CollisionAssets(landblockId, 10f)));
Assert.Throws<FixtureCollisionPublicationException>(() =>
HeadlessCollisionGenerationTransaction.Execute(
physics,
landblockId,
afterAdmission: null,
(admission, prepared) =>
{
physics.StageCollisionAssets(
admission,
prepared,
CollisionAssets(landblockId, 25f));
throw new FixtureCollisionPublicationException();
}));
Assert.Equal(10f, physics.Engine.SampleTerrainZ(1f, 1f));
RuntimePhysicsOwnershipSnapshot ownership = physics.CaptureOwnership();
Assert.Equal(1, ownership.LandblockCount);
Assert.Equal(0, ownership.CollisionAdmissionCount);
}
private static HeadlessSessionDescriptor Descriptor(
HeadlessCredentialProviderKind provider =
HeadlessCredentialProviderKind.Environment,
@ -422,6 +489,25 @@ public sealed class HeadlessSessionHostTests
runtime.MovementOwner.Controller = controller;
}
private static RuntimeLandblockCollisionAssets CollisionAssets(
uint landblockId,
float terrainHeight)
{
var heights = new byte[81];
var table = new float[256];
table[0] = terrainHeight;
return new RuntimeLandblockCollisionAssets(
landblockId,
new TerrainSurface(heights, table),
Array.Empty<CellSurface>(),
Array.Empty<PortalPlane>(),
0f,
0f,
0u);
}
private sealed class FixtureCollisionPublicationException : Exception;
private static void AddFlatLandblock(PhysicsEngine engine)
{
var heights = new byte[81];

View file

@ -322,7 +322,7 @@ public sealed class RuntimePhysicsStateTests
prepared,
CollisionAssets(0xA9B4FFFFu));
RuntimeCollisionGenerationCommit commit =
first.Physics.CommitCollisionGeneration(newer, prepared);
CommitPrepared(first.Physics, newer, prepared);
RuntimeCollisionAcknowledgement completed = commit.Acknowledgement;
Assert.True(commit.Committed);
@ -332,7 +332,7 @@ public sealed class RuntimePhysicsStateTests
0,
first.Physics.CaptureOwnership().CollisionAdmissionCount);
Assert.Throws<InvalidOperationException>(() =>
first.Physics.CommitCollisionGeneration(newer, prepared));
CommitPrepared(first.Physics, newer, prepared));
RuntimeCollisionAcknowledgement withdrawn =
first.Physics.WithdrawCollision(0xA9B4FFFFu);
@ -355,7 +355,7 @@ public sealed class RuntimePhysicsStateTests
firstAdmission,
first,
CollisionAssets(0xA9B4FFFFu, terrainHeight: 10f));
Assert.True(physics.CommitCollisionGeneration(
Assert.True(CommitPrepared(physics,
firstAdmission,
first).Committed);
}
@ -375,7 +375,7 @@ public sealed class RuntimePhysicsStateTests
Assert.Equal(0, notifications);
RuntimeCollisionGenerationCommit committed =
physics.CommitCollisionGeneration(
CommitPrepared(physics,
replacementAdmission,
replacement);
@ -401,7 +401,11 @@ public sealed class RuntimePhysicsStateTests
RuntimeCollisionAdmission current =
physics.BeginCollisionAdmission(0xA9B4FFFFu);
Assert.Throws<InvalidOperationException>(() =>
physics.CommitCollisionGeneration(stale, preparedStale));
CommitPrepared(physics, stale, preparedStale));
physics.CancelCollisionGeneration(stale, preparedStale);
Assert.Equal(
1,
physics.CaptureOwnership().CollisionAdmissionCount);
Assert.False(physics.Engine.IsLandblockTerrainResident(0xA9B4FFFFu));
using PreparedLandblockCollisionGeneration preparedCurrent =
@ -410,14 +414,14 @@ public sealed class RuntimePhysicsStateTests
current,
preparedCurrent,
CollisionAssets(0xA9B4FFFFu, terrainHeight: 20f));
Assert.True(physics.CommitCollisionGeneration(
Assert.True(CommitPrepared(physics,
current,
preparedCurrent).Committed);
Assert.Equal(20f, physics.Engine.SampleTerrainZ(1f, 1f));
}
[Fact]
public void MovingDynamicOwnerDuringStagingMustRefreshBeforeCommit()
public void WithdrawnOwnerStateChangeRejectsSealUntilRefreshed()
{
using var lifetime = new RuntimeEntityObjectLifetime();
RuntimePhysicsState physics = lifetime.Physics;
@ -430,7 +434,7 @@ public sealed class RuntimePhysicsStateTests
initialAdmission,
initial,
CollisionAssets(0x0101FFFFu, terrainHeight: 5f));
Assert.True(physics.CommitCollisionGeneration(
Assert.True(CommitPrepared(physics,
initialAdmission,
initial).Committed);
}
@ -445,6 +449,9 @@ public sealed class RuntimePhysicsStateTests
0x0101FFFFu,
seedCellId: 0x01010001u,
isStatic: false);
physics.Engine.ShadowObjects.RemoveLandblock(0x0101FFFFu);
Assert.Equal(0, physics.Engine.ShadowObjects.TotalRegistered);
Assert.Equal(1, physics.Engine.ShadowObjects.RetainedRegistrationCount);
RuntimeCollisionAdmission admission =
physics.BeginCollisionAdmission(0x0101FFFFu);
@ -454,33 +461,125 @@ public sealed class RuntimePhysicsStateTests
admission,
prepared,
CollisionAssets(0x0101FFFFu, terrainHeight: 15f));
uint owner = Assert.Single(physics.CaptureCollisionDynamicOwners(
admission,
prepared));
physics.RefreshCollisionDynamicOwner(admission, prepared, owner);
physics.Engine.ShadowObjects.UpdatePosition(
owner,
new Vector3(30f, 10f, 0f),
Quaternion.Identity,
0f,
0f,
0x0101FFFFu,
seedCellId: 0x01010009u);
uint owner = Assert.Single(SealPrepared(physics, admission, prepared));
physics.Engine.ShadowObjects.UpdatePhysicsState(owner, 0x14u);
RuntimeCollisionGenerationCommit rejected =
physics.CommitCollisionGeneration(admission, prepared);
Assert.False(rejected.Committed);
Assert.Equal(owner, Assert.Single(rejected.DirtyDynamicOwnerIds));
Assert.Equal(5f, physics.Engine.SampleTerrainZ(1f, 1f));
physics.RefreshCollisionDynamicOwner(admission, prepared, owner);
Assert.Equal(owner, Assert.Single(SealPrepared(
physics,
admission,
prepared)));
Assert.True(physics.CommitCollisionGeneration(
admission,
prepared).Committed);
Assert.Equal(15f, physics.Engine.SampleTerrainZ(1f, 1f));
Assert.Contains(
physics.Engine.ShadowObjects.GetObjectsInCell(0x01010009u),
entry => entry.EntityId == owner);
ShadowEntry restored = Assert.Single(
physics.Engine.ShadowObjects.AllEntriesForDebug());
Assert.Equal(owner, restored.EntityId);
Assert.Equal(0x14u, restored.State);
}
[Fact]
public void DenseReplacementSealsOneWorkUnitPerStepAndActivatesWithoutAllocation()
{
using var lifetime = new RuntimeEntityObjectLifetime();
RuntimePhysicsState physics = lifetime.Physics;
const uint landblockId = 0x0101FFFFu;
RuntimeCollisionAdmission initialAdmission =
physics.BeginCollisionAdmission(landblockId);
using (PreparedLandblockCollisionGeneration initial =
physics.PrepareCollisionGeneration(initialAdmission))
{
physics.StageCollisionAssets(
initialAdmission,
initial,
CollisionAssets(landblockId, terrainHeight: 5f));
Assert.True(CommitPrepared(
physics,
initialAdmission,
initial).Committed);
}
const int ownerCount = 256;
for (uint index = 0; index < ownerCount; index++)
{
physics.Engine.ShadowObjects.Register(
entityId: 1000u + index,
gfxObjId: 0x01000001u,
worldPos: new Vector3(
8f + (index % 16u) * 0.25f,
8f + (index / 16u) * 0.25f,
0f),
rotation: Quaternion.Identity,
radius: 0.5f,
worldOffsetX: 0f,
worldOffsetY: 0f,
landblockId,
seedCellId: 0x01010001u,
isStatic: false);
}
RuntimeCollisionAdmission admission =
physics.BeginCollisionAdmission(landblockId);
using PreparedLandblockCollisionGeneration prepared =
physics.PrepareCollisionGeneration(admission);
physics.StageCollisionAssets(
admission,
prepared,
CollisionAssets(landblockId, terrainHeight: 15f));
int captureSteps = 0;
RuntimeCollisionOwnerCaptureStep capture;
do
{
capture = physics.AdvanceCollisionRetainedOwnerCapture(
admission,
prepared);
captureSteps++;
}
while (!capture.Completed);
Assert.True(captureSteps >= ownerCount);
Assert.Equal(ownerCount, prepared.RetainedOwnerIds.Count);
foreach (uint ownerId in prepared.RetainedOwnerIds)
{
physics.RefreshCollisionRetainedOwner(
admission,
prepared,
ownerId);
}
int sealSteps = 0;
int workUnits = 0;
RuntimeCollisionSealStep seal;
do
{
seal = physics.AdvanceCollisionGenerationSeal(
admission,
prepared);
sealSteps++;
Assert.InRange(seal.WorkUnits, 0, 1);
workUnits += seal.WorkUnits;
}
while (!seal.Completed);
Assert.False(seal.Restarted);
Assert.True(sealSteps > ownerCount);
Assert.True(workUnits > ownerCount);
_ = GC.GetAllocatedBytesForCurrentThread();
long before = GC.GetAllocatedBytesForCurrentThread();
RuntimeCollisionGenerationCommit commit =
physics.CommitCollisionGeneration(admission, prepared);
long allocated =
GC.GetAllocatedBytesForCurrentThread() - before;
Assert.True(commit.Committed);
Assert.Equal(0L, allocated);
Assert.Equal(15f, physics.Engine.SampleTerrainZ(1f, 1f));
Assert.Equal(ownerCount, physics.Engine.ShadowObjects.TotalRegistered);
}
[Fact]
@ -496,7 +595,7 @@ public sealed class RuntimePhysicsStateTests
admission,
prepared,
CollisionAssets(0x0101FFFFu, terrainHeight: 12f));
Assert.Empty(physics.CaptureCollisionDynamicOwners(admission, prepared));
Assert.Empty(SealPrepared(physics, admission, prepared));
physics.Engine.ShadowObjects.Register(
77u,
@ -512,16 +611,17 @@ public sealed class RuntimePhysicsStateTests
RuntimeCollisionGenerationCommit spawned =
physics.CommitCollisionGeneration(admission, prepared);
Assert.False(spawned.Committed);
Assert.Equal(77u, Assert.Single(spawned.DirtyDynamicOwnerIds));
physics.RefreshCollisionDynamicOwner(admission, prepared, 77u);
Assert.Equal(77u, Assert.Single(SealPrepared(
physics,
admission,
prepared)));
physics.Engine.ShadowObjects.Deregister(77u);
RuntimeCollisionGenerationCommit deleted =
physics.CommitCollisionGeneration(admission, prepared);
Assert.False(deleted.Committed);
Assert.Equal(77u, Assert.Single(deleted.DirtyDynamicOwnerIds));
physics.RefreshCollisionDynamicOwner(admission, prepared, 77u);
Assert.Empty(SealPrepared(physics, admission, prepared));
Assert.True(physics.CommitCollisionGeneration(
admission,
prepared).Committed);
@ -546,7 +646,7 @@ public sealed class RuntimePhysicsStateTests
admission,
prepared,
CollisionAssets(0x0101FFFFu));
_ = lifetime.Physics.CommitCollisionGeneration(admission, prepared);
_ = CommitPrepared(lifetime.Physics, admission, prepared);
lifetime.Physics.Engine.ShadowObjects.Register(
entityId: record.LocalEntityId!.Value,
gfxObjId: 0x01000001u,
@ -930,6 +1030,49 @@ public sealed class RuntimePhysicsStateTests
Assert.Equal(record.SpatialAuthorityVersion, observed.SpatialAuthorityVersion);
}
private static RuntimeCollisionGenerationCommit CommitPrepared(
RuntimePhysicsState physics,
RuntimeCollisionAdmission admission,
PreparedLandblockCollisionGeneration prepared)
{
_ = SealPrepared(physics, admission, prepared);
return physics.CommitCollisionGeneration(admission, prepared);
}
private static uint[] SealPrepared(
RuntimePhysicsState physics,
RuntimeCollisionAdmission admission,
PreparedLandblockCollisionGeneration prepared)
{
while (true)
{
while (!physics.AdvanceCollisionRetainedOwnerCapture(
admission,
prepared).Completed)
{
}
foreach (uint ownerId in prepared.RetainedOwnerIds)
{
physics.RefreshCollisionRetainedOwner(
admission,
prepared,
ownerId);
}
RuntimeCollisionSealStep seal;
do
{
seal = physics.AdvanceCollisionGenerationSeal(
admission,
prepared);
Assert.InRange(seal.WorkUnits, 0, 1);
}
while (!seal.Completed && !seal.Restarted);
if (seal.Completed)
break;
}
return [.. prepared.RetainedOwnerIds];
}
private static RuntimeLandblockCollisionAssets CollisionAssets(
uint landblockId,
float terrainHeight = 0f)