diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md index 4e892324..d7b4cffb 100644 --- a/docs/architecture/acdream-architecture.md +++ b/docs/architecture/acdream-architecture.md @@ -485,8 +485,21 @@ What exists and is active: collision assets loaded from the validated prepared package. Production retains no parsed DAT collision graph; graph construction is restricted to bake/equivalence tools and explicit test oracles. -- `ShadowObjectRegistry` gives movement a broadphase over nearby objects and - buildings. +- 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. +- `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 Z" descriptions are historical B.3 language, not current architecture. diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 5d243c77..c531da62 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -89,7 +89,7 @@ AD-53..AD-55 (Campaign P response-layer findings). | AD-1 | Lost-cell machinery replaced by recoverable outdoor demote (**#107** safety net) + outdoor-restore `max(terrainZ, z)` under-terrain lift; retail goes `GotoLostCell` | `src/AcDream.Core/Physics/PhysicsEngine.cs:553` (+ :808) | acdream has no lost-cell state machine; outdoor landcell is the recoverable equivalent; the #107 auto-entry hold should make the demote branch unreachable | Gap in the hold → player committed to outdoor terrain inside/under a building (fake-grounded spawn, fall-through); a legit below-heightmap server restore is silently lifted — upward warp vs server | `GotoLostCell` pc:283418; `SetPositionInternal` 0x00515bd0, pc:283892-283945 | | AD-2 | Async readiness gates replace retail's synchronous destination cell load. **#229 refinement (2026-07-20):** login and F751 portal-space exit now share `WorldRevealReadinessBarrier`, so neither path can expose the normal viewport until the same render-publication, composite-texture, and collision domains converge. A hydratable indoor claim requires its owning Near-tier static/EnvCell mesh set, destination composites, and exact EnvCell physics (`IsSpawnCellReady`); an outdoor claim requires those render domains plus terrain/collision residency for the required Near ring. Hard-recenter generations and tier-aware completion application prevent stale overlapping loads/unloads or Far/Near jobs from opening or erasing the gate; mesh upload remains separate from balanced landblock ownership. Claims beyond NumCells still take the loud unhydratable-placement path. `RuntimeWorldTransitState` owns the shared reveal generation, accepted readiness, transit correlation, and exact generation/cell-scoped host-acknowledgement suffix. `WorldRevealCoordinator` is a graphical adapter holding only App resource receipts; normalized Runtime checkpoints observe ownership without defining another readiness path. **Slice E3 refinement (2026-07-24):** the same generation now publishes an immediate `WorldGenerationQuiescence` edge: old-world drawing/spatial queries, simulation/effect clocks, reconciliation, targeting, and 3-D audio stop while retained physical teardown advances through metered cursors and destination network/UI/streaming/readiness remain live. **Slice E4 refinement (2026-07-24):** accepted render/physics/static publication may span update frames through retained exact cursors, but reveal still consumes only the completed spatial/render-ready generation; building and EnvCell snapshots remain invisible until complete and the final spatial identity swap stays observer-atomic. **Slice E5 refinement (2026-07-24):** the reveal generation owns one exact destination reservation across every typed budget dimension. Stale completion cannot consume or clear its replacement, and hydratable incomplete content is never force-revealed; portal transit retains the DAT tunnel and centered retail wait cue until readiness converges. The hold→materialize→regain-control lifecycle remains owned by `TeleportAnimSequencer`. | `src/AcDream.Runtime/World/RuntimeWorldTransitState.cs`; `src/AcDream.App/Streaming/WorldRevealCoordinator.cs`; `src/AcDream.App/Streaming/WorldGenerationQuiescence.cs`; `src/AcDream.App/Streaming/WorldRevealReadinessBarrier.cs`; `src/AcDream.App/Streaming/StreamingOriginRecenterCoordinator.cs`; `src/AcDream.App/Streaming/LandblockPresentationPipeline.cs`; `src/AcDream.App/Streaming/StreamingController.cs`; `src/AcDream.App/Rendering/PortalTunnelPresentation.cs`; `src/AcDream.App/UI/PortalWaitNoticeController.cs`; `src/AcDream.App/Streaming/GpuWorldState.cs` (`IsRenderReady`); `src/AcDream.App/Rendering/Wb/LandblockSpawnAdapter.cs`; `src/AcDream.Core/Physics/PhysicsEngine.cs` (`IsSpawnCellReady`, `IsNeighborhoodTerrainResident`) | This is the asynchronous equivalent of retail leaving `SmartBox::position_update_complete` false while `CellManager::blocking_for_cells` is set: neither initial login nor portal arrival may reveal or continue simulating an old/partial collision world, a terrain-only Far shell, or a published-but-not-drawable GPU landblock. Indoor does not require a terrain heightmap, only the owning render landblock and exact EnvCell. | Gate opens early → grey/untextured first login or portal reveal, free-fall, wrong-cell rooting, missing scenery, or a still-active old generation; predicate never satisfies (streamer/DAT/upload failure) → login remains behind the world render gate, while portal transit remains in the authored tunnel and presents the centered wait cue after five seconds. | `SmartBox::UseTime` 0x00455410; `gmSmartBoxUI::UseTime` 0x004D6E30; `gmSmartBoxUI::EndTeleportAnimation` 0x004D65A0 | | AD-5 | Outdoor `point_in_cell` is an identity compare against the global XY-column cell from `LandDefs.AdjustToOutside` (no per-cell containment test) | `src/AcDream.Core/Physics/CellTransit.cs:865` | Landcells are disjoint 24 m columns — identity-compare against the column under the sphere centre is exactly equivalent to retail's per-candidate test | If block-origin/lcoord math is wrong at a landblock seam, the compare silently never matches — outdoor membership freezes at boundaries (the pre-#106 symptom) | `find_cell_list` pick pc:308788-308825; `CLandCell::point_in_cell` (get_block_offset pc:308804) | -| AD-6 | 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-11~~ | **RETIRED 2026-07-23** — the matching binary disproved the old nonzero interpretation: `ItemUses::IsUseable` executes `not bitfield; and eax,1`, so absent/reset zero is usable and only `USEABLE_NO` disables use. Toolbar, item policy, and world interaction now share that exact Core predicate. | `src/AcDream.Core/Items/ClientObject.cs` (`ItemUseability.IsUseable`); `src/AcDream.Core/Items/ItemInteractionPolicy.cs`; `src/AcDream.App/Interaction/WorldSelectionQuery.cs` | — | — | `ItemUses::IsUseable @ 0x004FCCC0`; matching v11.4186 instructions recorded in `docs/research/2026-07-23-retail-item-use-and-autowear-pseudocode.md` | | AD-12 | SecondaryAttributeTable coefficients hardcoded (Health=End×0.5, Stam=End×1.0, Mana=Self×1.0) instead of dat-read; unknown attributes contribute 0 | `src/AcDream.Core/Player/LocalPlayerState.cs:279` | Coefficients never vary across retail dat versions; re-confirmed by ACE AttributeFormula.cs + holtburger; dat port can replace later | A customized portal.dat with modified vital formulas silently yields wrong max-vitals; a missing attribute snapshot underestimates max | SecondaryAttributeTable portal.dat 0x0E0..0x0E2; `CreatureVital::GetMaxValue` 0x0058F2DD | diff --git a/docs/research/2026-07-31-atomic-collision-generation.md b/docs/research/2026-07-31-atomic-collision-generation.md new file mode 100644 index 00000000..abdeb784 --- /dev/null +++ b/docs/research/2026-07-31-atomic-collision-generation.md @@ -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. diff --git a/memory/project_collision_port.md b/memory/project_collision_port.md index 1866b886..1da3577c 100644 --- a/memory/project_collision_port.md +++ b/memory/project_collision_port.md @@ -10,6 +10,14 @@ as "delete everything and start over." A partial retail transition port exists: - `TransitionTypes` carries the active `SpherePath`, `CollisionInfo`, transition, step, contact, and partial slide logic. - `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. - `TerrainSurface` uses triangle-aware terrain contact. diff --git a/src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs b/src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs index 169d912c..ffccba70 100644 --- a/src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs +++ b/src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs @@ -12,7 +12,7 @@ namespace AcDream.App.Streaming; /// publication. The render publisher commits buildings and EnvCells between /// these stages without recomputing the captured origin. /// -public sealed class LandblockPhysicsPublication +public sealed class LandblockPhysicsPublication : IDisposable { internal LandblockPhysicsPublication( object owner, @@ -21,7 +21,8 @@ public sealed class LandblockPhysicsPublication uint currentCellId, BuildingInfo[] buildings, uint[] priorStaticOwnerIds, - RuntimeCollisionAdmission collisionAdmission) + RuntimeCollisionAdmission collisionAdmission, + PreparedLandblockCollisionGeneration preparedGeneration) { Owner = owner; Build = build; @@ -30,6 +31,7 @@ public sealed class LandblockPhysicsPublication Buildings = buildings; PriorStaticOwnerIds = priorStaticOwnerIds; CollisionAdmission = collisionAdmission; + PreparedGeneration = preparedGeneration; } internal object Owner { get; } @@ -38,6 +40,9 @@ public sealed class LandblockPhysicsPublication internal BuildingInfo[] Buildings { get; } internal uint[] PriorStaticOwnerIds { get; } internal RuntimeCollisionAdmission CollisionAdmission { get; } + internal PreparedLandblockCollisionGeneration PreparedGeneration { get; } + internal PhysicsDataCache StagingCache => PreparedGeneration.DataCache; + internal PhysicsEngine StagingEngine => PreparedGeneration.Engine; internal SortedSet GfxObjectIdSet { get; } = new(); internal uint[] GfxObjectIds { get; set; } = Array.Empty(); internal int PreparationCursor { get; set; } @@ -64,6 +69,12 @@ public sealed class LandblockPhysicsPublication internal bool BeginCommitted { get; set; } internal bool CompletionCommitted { get; set; } + public void Dispose() + { + if (!CompletionCommitted) + PreparedGeneration.Dispose(); + } + public uint LandblockId => Build.Landblock.LandblockId; public Vector3 Origin { get; } } @@ -204,6 +215,8 @@ public sealed class LandblockPhysicsPublisher build.Landblock.PhysicsDats ?? PhysicsDatBundle.Empty; BuildingInfo[] buildings = datBundle.Info?.Buildings.ToArray() ?? Array.Empty(); + RuntimeCollisionAdmission collisionAdmission = + _physics.BeginCollisionAdmission(build.Landblock.LandblockId); var publication = new LandblockPhysicsPublication( _receiptOwner, build, @@ -212,11 +225,14 @@ public sealed class LandblockPhysicsPublisher buildings, _physicsEngine.ShadowObjects.CaptureStaticOwnersForLandblock( build.Landblock.LandblockId), - _physics.BeginCollisionAdmission( - 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; } @@ -237,6 +253,9 @@ public sealed class LandblockPhysicsPublisher if (publication.Build.Collisions is { } collisions) { publication.GfxObjectIds = [.. collisions.GfxObjIds]; + publication.PreparedGeneration.SetAssetClosure( + publication.GfxObjectIds, + publication.SetupObjectIds); publication.PreparationCursor = entities.Count; publication.PreparationCommitted = true; return true; @@ -256,6 +275,9 @@ public sealed class LandblockPhysicsPublisher } publication.GfxObjectIds = publication.GfxObjectIdSet.ToArray(); + publication.PreparedGeneration.SetAssetClosure( + publication.GfxObjectIds, + publication.SetupObjectIds); publication.PreparationCommitted = true; return true; } @@ -299,9 +321,9 @@ public sealed class LandblockPhysicsPublisher // CacheCellStruct/CacheBuilding use first-wins semantics within one // publication, so the replacement pass starts with one exact // landblock-scoped withdrawal. - _physicsDataCache.RemoveCellsForLandblock(landblock.LandblockId); - _physicsDataCache.RemoveBuildingsForLandblock(landblock.LandblockId); - _physicsDataCache.CellGraph.RemoveEnvCellsForLandblock( + publication.StagingCache.RemoveCellsForLandblock(landblock.LandblockId); + publication.StagingCache.RemoveBuildingsForLandblock(landblock.LandblockId); + publication.StagingCache.CellGraph.RemoveEnvCellsForLandblock( landblock.LandblockId); publication.PriorCacheRemoved = true; } @@ -330,6 +352,7 @@ public sealed class LandblockPhysicsPublisher PublishBuilding( landblock, datBundle, + publication.StagingCache, publication.TerrainSurface, origin, publication.Buildings[publication.BuildingCursor]); @@ -337,8 +360,9 @@ public sealed class LandblockPhysicsPublisher } else if (!publication.BaseCommitted) { - _physics.AdmitCollisionAssets( + _physics.StageCollisionAssets( publication.CollisionAdmission, + publication.PreparedGeneration, new RuntimeLandblockCollisionAssets( landblock.LandblockId, publication.TerrainSurface, @@ -409,7 +433,7 @@ public sealed class LandblockPhysicsPublisher gfxObjectId, out FlatGfxObjCollisionAsset? prepared) == true) { - _physicsDataCache.CacheGfxObj(gfxObjectId, prepared); + publication.StagingCache.CacheGfxObj(gfxObjectId, prepared); } else if (datBundle.GfxObjs.TryGetValue( gfxObjectId, @@ -417,7 +441,7 @@ public sealed class LandblockPhysicsPublisher { // Graph-oracle fixture seam. Production near builds always // carry the strict prepared closure. - _physicsDataCache.CacheGfxObj(gfxObjectId, source); + publication.StagingCache.CacheGfxObj(gfxObjectId, source); } publication.GfxCursor++; _gfxCacheTicks += Stopwatch.GetTimestamp() - cacheStarted; @@ -430,19 +454,19 @@ public sealed class LandblockPhysicsPublisher setupId, out FlatSetupCollision? prepared) == true) { - _physicsDataCache.CacheSetup(setupId, prepared); + publication.StagingCache.CacheSetup(setupId, prepared); } else if (datBundle.Setups.TryGetValue(setupId, out var source)) { // Graph-oracle fixture seam only. - _physicsDataCache.CacheSetup(setupId, source); + publication.StagingCache.CacheSetup(setupId, source); } publication.SetupCursor++; } else if (publication.PriorStaticCursor < publication.PriorStaticOwnerIds.Length) { - _physicsEngine.ShadowObjects.DeregisterStaticOwnerForLandblock( + publication.StagingEngine.ShadowObjects.DeregisterStaticOwnerForLandblock( publication.PriorStaticOwnerIds[ publication.PriorStaticCursor], landblock.LandblockId); @@ -458,15 +482,17 @@ public sealed class LandblockPhysicsPublisher else if (publication.RefloodOwnerIds is null) { publication.RefloodOwnerIds = - _physicsEngine.ShadowObjects - .CaptureRefloodOwnersForLandblock(landblock.LandblockId); + _physics.CaptureCollisionDynamicOwners( + publication.CollisionAdmission, + publication.PreparedGeneration); } else if (publication.RefloodCursor < publication.RefloodOwnerIds.Length) { - _physicsEngine.ShadowObjects.RefloodOwnerForLandblock( - publication.RefloodOwnerIds[publication.RefloodCursor], - landblock.LandblockId); + _physics.RefreshCollisionDynamicOwner( + publication.CollisionAdmission, + publication.PreparedGeneration, + publication.RefloodOwnerIds[publication.RefloodCursor]); publication.RefloodCursor++; } else if (!publication.RefloodCommitted) @@ -478,14 +504,24 @@ public sealed class LandblockPhysicsPublisher $"lb 0x{landblock.LandblockId:X8}: scenery tried={publication.SceneryTried} " + $"(outdoorNone={publication.NoCollisionCount})"); } - LogMissingSceneryBounds(landblock); - _refloodCount++; + LogMissingSceneryBounds(landblock, publication.StagingCache); publication.RefloodCommitted = true; } else { - _physics.CompleteCollisionAdmission( - publication.CollisionAdmission); + RuntimeCollisionGenerationCommit commit = + _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; _staticCylinderOwnerCount += publication.CylinderOwnerCount; publication.CompletionCommitted = true; @@ -545,7 +581,7 @@ public sealed class LandblockPhysicsPublisher Matrix4x4.CreateFromQuaternion(rotation) * Matrix4x4.CreateTranslation(cellOriginWorld); - _physicsDataCache.CacheCellStruct( + publication.StagingCache.CacheCellStruct( envCellId, envCell, physicsCellTransform, @@ -623,7 +659,7 @@ public sealed class LandblockPhysicsPublisher Matrix4x4 physicsCellTransform = Matrix4x4.CreateFromQuaternion(rotation) * Matrix4x4.CreateTranslation(cellOriginWorld); - _physicsDataCache.CacheCellStruct( + publication.StagingCache.CacheCellStruct( envCellId, envCell, cellStruct, @@ -687,6 +723,7 @@ public sealed class LandblockPhysicsPublisher private void PublishBuilding( LoadedLandblock landblock, PhysicsDatBundle datBundle, + PhysicsDataCache cache, TerrainSurface terrainSurface, Vector3 origin, BuildingInfo building) @@ -720,7 +757,7 @@ public sealed class LandblockPhysicsPublisher ? setup.Parts[0] : 0u; } - _physicsDataCache.CacheBuilding( + cache.CacheBuilding( landcellId, portals, buildingTransform, @@ -752,11 +789,11 @@ public sealed class LandblockPhysicsPublisher ShadowShapeBuilder.FromLandblockBspParts( entity.MeshRefs, entity.IsBuildingShell, - _physicsDataCache.GetGfxObj); + publication.StagingCache.GetGfxObj); entityBspCount = bspShapes.Count; if (entityBspCount > 0) { - _physicsEngine.ShadowObjects.RegisterMultiPart( + publication.StagingEngine.ShadowObjects.RegisterMultiPart( entity.Id, entity.Position, entity.Rotation, @@ -772,9 +809,9 @@ public sealed class LandblockPhysicsPublisher } FlatSetupCollision? setup = - _physicsDataCache.GetFlatSetup(entity.SourceGfxObjOrSetupId); + publication.StagingCache.GetFlatSetup(entity.SourceGfxObjOrSetupId); if (setup is null - && _physicsDataCache.GetSetup( + && publication.StagingCache.GetSetup( entity.SourceGfxObjOrSetupId) is { } graphSetup) { // Graph-oracle fixture seam only. Production Setup publication is @@ -858,7 +895,7 @@ public sealed class LandblockPhysicsPublisher if (setupShapes.Count > 0) { - _physicsEngine.ShadowObjects.RegisterMultiPart( + publication.StagingEngine.ShadowObjects.RegisterMultiPart( entity.Id, entity.Position, 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) return; @@ -933,7 +972,7 @@ public sealed class LandblockPhysicsPublisher foreach (MeshRef meshRef in entity.MeshRefs) { GfxObjVisualBounds? bounds = - _physicsDataCache.GetVisualBounds(meshRef.GfxObjId); + cache.GetVisualBounds(meshRef.GfxObjId); if (bounds is not null && bounds.Radius > 0f) { hasBounds = true; diff --git a/src/AcDream.App/Streaming/LandblockPresentationPipeline.cs b/src/AcDream.App/Streaming/LandblockPresentationPipeline.cs index f5ae4f9d..6f18c36c 100644 --- a/src/AcDream.App/Streaming/LandblockPresentationPipeline.cs +++ b/src/AcDream.App/Streaming/LandblockPresentationPipeline.cs @@ -215,6 +215,18 @@ public sealed class LandblockPresentationPipeline public IReadOnlyList GetPendingPublicationResults() => _publications.Keys.ToArray(); + /// + /// 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. + /// + internal void CancelPendingPublications() + { + foreach (PublicationTransaction transaction in _publications.Values) + transaction.PhysicsPublication?.Dispose(); + _publications.Clear(); + } + public void ResumePublication(LandblockStreamResult result) { ArgumentNullException.ThrowIfNull(result); diff --git a/src/AcDream.App/Streaming/StreamingController.cs b/src/AcDream.App/Streaming/StreamingController.cs index 08f20cc8..6f4c7912 100644 --- a/src/AcDream.App/Streaming/StreamingController.cs +++ b/src/AcDream.App/Streaming/StreamingController.cs @@ -32,6 +32,7 @@ public sealed class StreamingController public bool GenerationAdvanced; public bool PendingLoadsCleared; public bool CompletionQueueCleared; + public bool PendingPublicationsCleared; public bool RegionCleared; public bool SpatialGenerationDetached; public bool PreparationCommitted; @@ -45,6 +46,7 @@ public sealed class StreamingController public bool GenerationAdvanced; public bool PendingLoadsCleared; public bool CompletionQueueCleared; + public bool PendingPublicationsCleared; public bool RegionCleared; public List? ResidentIds; public IEnumerator? ResidentEnumerator; @@ -1358,6 +1360,22 @@ public sealed class StreamingController } 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( meter, new StreamingWorkCost(EntityOperations: 1), @@ -1499,6 +1517,22 @@ public sealed class StreamingController 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( meter, new StreamingWorkCost(EntityOperations: 1), diff --git a/src/AcDream.Core/AcDream.Core.csproj b/src/AcDream.Core/AcDream.Core.csproj index 128df795..cf3567d5 100644 --- a/src/AcDream.Core/AcDream.Core.csproj +++ b/src/AcDream.Core/AcDream.Core.csproj @@ -20,6 +20,9 @@ <_Parameter1>AcDream.Core.Tests + + <_Parameter1>AcDream.Runtime + diff --git a/src/AcDream.Core/Physics/PhysicsDataCache.cs b/src/AcDream.Core/Physics/PhysicsDataCache.cs index 6e8f019c..ba9b3e36 100644 --- a/src/AcDream.Core/Physics/PhysicsDataCache.cs +++ b/src/AcDream.Core/Physics/PhysicsDataCache.cs @@ -6,6 +6,7 @@ using DatReaderWriter.Types; using Plane = System.Numerics.Plane; using UcgEnvCell = AcDream.Core.World.Cells.EnvCell; using UcgCellGraph = AcDream.Core.World.Cells.CellGraph; +using PreparedCellGraphLandblock = AcDream.Core.World.Cells.PreparedCellGraphLandblock; namespace AcDream.Core.Physics; @@ -21,6 +22,7 @@ namespace AcDream.Core.Physics; public sealed class PhysicsDataCache { private readonly bool _requirePreparedCollision; + private PhysicsDataCache? _readFallback; private readonly ConcurrentDictionary _gfxObj = new(); private readonly ConcurrentDictionary _visualBounds = new(); private readonly ConcurrentDictionary _setup = new(); @@ -92,7 +94,112 @@ public sealed class PhysicsDataCache /// (TryGetTerrainOrigin, read by CellTransit's pick + transit /// paths). No longer inert. /// - public UcgCellGraph CellGraph { get; } = new(); + public UcgCellGraph CellGraph { get; private set; } = new(); + + /// + /// 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. + /// + 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 gfxObjectIds, + ReadOnlySpan setupIds) + { + uint prefix = landblockId & 0xFFFF0000u; + return new PreparedPhysicsDataCacheLandblock( + prefix, + CaptureRequested(_gfxObj, gfxObjectIds), + CaptureRequested(_visualBounds, gfxObjectIds), + CaptureRequested(_flatGfxObj, gfxObjectIds), + CaptureRequested(_setup, setupIds), + CaptureRequested(_flatSetup, setupIds), + CapturePrefix(_cellStruct, prefix), + CapturePrefix(_flatCellStruct, prefix), + CapturePrefix(_flatEnvCell, prefix), + CapturePrefix(_buildings, prefix), + CellGraph.PrepareLandblockReplacement(prefix)); + } + + 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( + ConcurrentDictionary source, + ConcurrentDictionary destination) + { + foreach ((uint id, T value) in source) + destination.TryAdd(id, value); + } + + private static KeyValuePair[] CaptureRequested( + ConcurrentDictionary source, + ReadOnlySpan ids) + { + var result = new List>(ids.Length); + for (int index = 0; index < ids.Length; index++) + { + uint id = ids[index]; + if (source.TryGetValue(id, out T? value)) + result.Add(new KeyValuePair(id, value)); + } + return result.ToArray(); + } + + private static KeyValuePair[] CapturePrefix( + ConcurrentDictionary source, + uint prefix) => source + .Where(pair => (pair.Key & 0xFFFF0000u) == prefix) + .OrderBy(static pair => pair.Key) + .ToArray(); + + private static void CommitEntries( + ConcurrentDictionary destination, + KeyValuePair[] entries, + bool replace) + { + foreach ((uint id, T value) in entries) + { + if (replace) + destination[id] = value; + else + destination.TryAdd(id, value); + } + } /// /// 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. /// public GfxObjVisualBounds? GetVisualBounds(uint gfxObjId) => - _visualBounds.TryGetValue(gfxObjId, out var vb) ? vb : null; + _visualBounds.TryGetValue(gfxObjId, out var vb) + ? vb + : _readFallback?.GetVisualBounds(gfxObjId); /// /// 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. " + "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 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) => - _flatSetup.TryGetValue(id, out var value) ? value : null; + _flatSetup.TryGetValue(id, out var value) + ? value + : _readFallback?.GetFlatSetup(id); public FlatCellStructureCollisionAsset? GetFlatCellStruct(uint id) => _flatCellStruct.TryGetValue(id, out var value) ? value : null; public FlatEnvCellTopology? GetFlatEnvCell(uint id) => @@ -926,6 +1045,19 @@ public sealed class PhysicsDataCache public void RegisterBuildingForTest(uint landcellId, BuildingPhysics b) => _buildings[landcellId] = b; } +internal sealed record PreparedPhysicsDataCacheLandblock( + uint LandblockPrefix, + KeyValuePair[] GfxObjects, + KeyValuePair[] VisualBounds, + KeyValuePair[] FlatGfxObjects, + KeyValuePair[] Setups, + KeyValuePair[] FlatSetups, + KeyValuePair[] Cells, + KeyValuePair[] FlatCells, + KeyValuePair[] FlatEnvCells, + KeyValuePair[] Buildings, + PreparedCellGraphLandblock CellGraph); + /// /// 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 diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs index 72347120..017b44c1 100644 --- a/src/AcDream.Core/Physics/PhysicsEngine.cs +++ b/src/AcDream.Core/Physics/PhysicsEngine.cs @@ -153,13 +153,106 @@ public sealed class PhysicsEngine /// public ClientObjectTable? Objects { get; set; } - private sealed record LandblockPhysics( + internal sealed record LandblockPhysics( TerrainSurface Terrain, IReadOnlyList Cells, IReadOnlyList Portals, float WorldOffsetX, float WorldOffsetY); + /// + /// 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. + /// + 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 gfxObjectIds, + ReadOnlySpan setupIds, + IReadOnlyDictionary 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; } + } + /// /// Register a landblock with its terrain surface, indoor cells, portal /// planes, and world-space origin offset. diff --git a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs index b511a81a..6e7afc6d 100644 --- a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs +++ b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs @@ -51,8 +51,9 @@ public sealed class ShadowObjectRegistry /// is the streaming-side trigger. /// private readonly Dictionary _entityReg = new(); + private readonly Dictionary _ownerVersions = new(); - private sealed record RegistrationRecord( + internal sealed record RegistrationRecord( uint SeedCellId, Vector3 EntityWorldPos, Quaternion EntityWorldRot, @@ -67,6 +68,16 @@ public sealed class ShadowObjectRegistry float CylHeight, 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); + } + /// /// The flood's data source (cells, buildings, terrain origins). Wired by /// when its own DataCache is set. @@ -135,6 +146,7 @@ public sealed class ShadowObjectRegistry _entityReg[entityId] = new RegistrationRecord( seed, worldPos, rotation, state, flags, isStatic, IsMultiPart: false, gfxObjId, radius, collisionType, cylHeight, scale); + BumpOwnerVersion(entityId); } /// @@ -214,6 +226,7 @@ public sealed class ShadowObjectRegistry seed, entityWorldPos, entityWorldRot, state, flags, isStatic, IsMultiPart: true, GfxObjId: 0u, Radius: 0f, CollisionType: ShadowCollisionType.BSP, CylHeight: 0f, Scale: 1f); + BumpOwnerVersion(entityId); } /// @@ -271,7 +284,10 @@ public sealed class ShadowObjectRegistry }; if (suspended || !_entityToCells.TryGetValue(entityId, out List? cells)) + { + BumpOwnerVersion(entityId); return; + } foreach (uint cellId in cells) { @@ -300,6 +316,7 @@ public sealed class ShadowObjectRegistry foreach (uint cellId in cells) AddEntryToCell(entry, cellId); } + BumpOwnerVersion(entityId); } /// @@ -441,6 +458,7 @@ public sealed class ShadowObjectRegistry } _suspendedEntities.Add(entityId); + BumpOwnerVersion(entityId); return true; } @@ -624,11 +642,16 @@ public sealed class ShadowObjectRegistry if (_entityReg.TryGetValue(entityId, out var reg)) _entityReg[entityId] = reg with { State = newState }; + BumpOwnerVersion(entityId); } /// Remove an entity from all cells it was registered in. 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)) { foreach (var cellId in cellIds) @@ -642,6 +665,8 @@ public sealed class ShadowObjectRegistry _entityReg.Remove(entityId); _suspendedEntities.Remove(entityId); _withdrawnPrefixesByOwner.Remove(entityId); + if (existed) + BumpOwnerVersion(entityId); } /// @@ -708,11 +733,13 @@ public sealed class ShadowObjectRegistry { uint lbPrefix = landblockId & 0xFFFF0000u; var toRemove = new List(); + var touchedOwners = new HashSet(); foreach (var (entityId, cells) in _entityToCells) { if (!cells.Exists(cell => (cell & 0xFFFF0000u) == lbPrefix)) continue; + touchedOwners.Add(entityId); if (!_withdrawnPrefixesByOwner.TryGetValue(entityId, out var withdrawn)) { withdrawn = new HashSet(); @@ -753,6 +780,8 @@ public sealed class ShadowObjectRegistry _withdrawnPrefixesByOwner.Remove(eid); } } + foreach (uint entityId in touchedOwners) + BumpOwnerVersion(entityId); } /// @@ -795,6 +824,315 @@ public sealed class ShadowObjectRegistry /// Suspended logical registrations awaiting spatial re-entry. public int SuspendedRegistrationCount => _suspendedEntities.Count; + /// + /// 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. + /// + internal void CopyCollisionStateFrom( + ShadowObjectRegistry source, + PhysicsDataCache stagingCache) + { + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(stagingCache); + Clear(); + DataCache = stagingCache; + foreach ((uint cellId, List entries) in source._cells) + _cells[cellId] = new List(entries); + foreach ((uint ownerId, List cells) in source._entityToCells) + _entityToCells[ownerId] = new List(cells); + foreach (uint ownerId in source._suspendedEntities) + _suspendedEntities.Add(ownerId); + foreach ((uint ownerId, HashSet prefixes) in + source._withdrawnPrefixesByOwner) + { + _withdrawnPrefixesByOwner[ownerId] = new HashSet(prefixes); + } + foreach ((uint ownerId, IReadOnlyList 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(); + } + + /// + /// 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. + /// + 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? 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 expectedVersions) + { + var dirty = new HashSet( + CaptureDynamicRefloodOwnersForLandblock(landblockId)); + dirty.UnionWith(expectedVersions.Keys); + dirty.RemoveWhere(ownerId => + expectedVersions.TryGetValue(ownerId, out ulong expected) + && OwnerTouchesLandblock(ownerId, landblockId) + && GetOwnerVersion(ownerId) == expected); + uint[] result = dirty.ToArray(); + Array.Sort(result); + return result; + } + + internal PreparedLandblockShadowReplacement PrepareLandblockReplacement( + ShadowObjectRegistry staging, + uint landblockId, + IReadOnlyDictionary 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(CaptureStaticOwnersForLandblock(landblockId)); + owners.UnionWith(staging.CaptureStaticOwnersForLandblock(landblockId)); + owners.UnionWith(expectedDynamicVersions.Keys); + uint[] ownerIds = owners.ToArray(); + Array.Sort(ownerIds); + var states = new List(ownerIds.Length); + foreach (uint ownerId in ownerIds) + { + if (staging.TryCaptureOwnerState(ownerId, out PreparedShadowOwnerState? state) + && state is not null) + states.Add(state); + } + return new PreparedLandblockShadowReplacement( + landblockId & 0xFFFF0000u, + ownerIds, + states.ToArray(), + expectedDynamicVersions.ToDictionary( + static pair => pair.Key, + static pair => pair.Value)); + } + + internal bool ValidateLandblockReplacement( + PreparedLandblockShadowReplacement replacement) + { + foreach ((uint ownerId, ulong version) in replacement.DynamicVersions) + { + if (GetOwnerVersion(ownerId) != version + || !OwnerTouchesLandblock(ownerId, replacement.LandblockPrefix)) + { + return false; + } + } + return CaptureDynamicRefloodOwnersForLandblock( + replacement.LandblockPrefix) + .SequenceEqual(replacement.DynamicVersions.Keys.Order()); + } + + 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? cells) + && cells.Exists(cell => (cell & 0xFFFF0000u) == prefix)) + { + return true; + } + return _withdrawnPrefixesByOwner.TryGetValue( + entityId, + out HashSet? 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? cells); + _entityShapes.TryGetValue( + entityId, + out IReadOnlyList? shapes); + _withdrawnPrefixesByOwner.TryGetValue( + entityId, + out HashSet? withdrawn); + var rows = new List(); + if (cells is not null) + { + foreach (uint cellId in cells) + { + if (_cells.TryGetValue(cellId, out List? entries)) + { + rows.Add(new PreparedShadowCellRows( + cellId, + entries.Where(entry => entry.EntityId == entityId) + .ToArray())); + } + } + } + state = new PreparedShadowOwnerState( + entityId, + registration, + shapes, + cells?.ToArray() ?? Array.Empty(), + rows.ToArray(), + _suspendedEntities.Contains(entityId), + withdrawn?.ToArray() ?? Array.Empty()); + 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(state.WithdrawnPrefixes); + } + if (state.CellIds.Length != 0) + _entityToCells[state.EntityId] = new List(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 dynamicVersions) + { + LandblockPrefix = landblockPrefix; + OwnerIds = ownerIds; + OwnerStates = ownerStates; + DynamicVersions = dynamicVersions; + } + + internal uint LandblockPrefix { get; } + internal uint[] OwnerIds { get; } + internal PreparedShadowOwnerState[] OwnerStates { get; } + internal Dictionary DynamicVersions { get; } + } + + internal sealed record PreparedShadowOwnerState( + uint EntityId, + RegistrationRecord Registration, + IReadOnlyList? Shapes, + uint[] CellIds, + PreparedShadowCellRows[] Rows, + bool Suspended, + uint[] WithdrawnPrefixes); + + internal sealed record PreparedShadowCellRows( + uint CellId, + ShadowEntry[] Entries); + /// /// Retires the complete logical registry at terminal physics-engine /// disposal, including suspended live registrations that own no cell row. @@ -807,6 +1145,7 @@ public sealed class ShadowObjectRegistry _withdrawnPrefixesByOwner.Clear(); _entityShapes.Clear(); _entityReg.Clear(); + _ownerVersions.Clear(); _fallback = null; } diff --git a/src/AcDream.Core/World/Cells/CellGraph.cs b/src/AcDream.Core/World/Cells/CellGraph.cs index 48fc0951..9d9e82ea 100644 --- a/src/AcDream.Core/World/Cells/CellGraph.cs +++ b/src/AcDream.Core/World/Cells/CellGraph.cs @@ -125,4 +125,76 @@ public sealed class CellGraph return stab; return null; } + + /// + /// Creates an immutable-reference snapshot for collision-generation + /// preparation. EnvCell and TerrainSurface records are immutable after + /// publication, so copying the registries is sufficient; the active graph + /// remains untouched while the staging graph is rebuilt. + /// + internal CellGraph CreateCollisionStagingCopy() + { + var copy = new CellGraph { CurrCell = CurrCell }; + foreach ((uint id, EnvCell cell) in _envCells) + copy._envCells.TryAdd(id, cell); + foreach ((uint id, (TerrainSurface Terrain, Vector3 Origin) terrain) in + _terrain) + { + copy._terrain.TryAdd(id, terrain); + } + return copy; + } + + internal PreparedCellGraphLandblock PrepareLandblockReplacement( + uint landblockId) + { + uint prefix = landblockId & 0xFFFF0000u; + KeyValuePair[] envCells = _envCells + .Where(static pair => (pair.Key & 0xFFFFu) >= 0x0100u) + .Where(pair => (pair.Key & 0xFFFF0000u) == prefix) + .OrderBy(static pair => pair.Key) + .ToArray(); + bool hasTerrain = _terrain.TryGetValue(prefix, out var terrain); + return new PreparedCellGraphLandblock( + prefix, + envCells, + hasTerrain, + terrain.Terrain, + terrain.Origin, + CurrCell?.Id ?? 0u); + } + + internal 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[] EnvCells, + bool HasTerrain, + TerrainSurface? Terrain, + Vector3 Origin, + uint CurrentCellId); diff --git a/src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs b/src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs index 8f364dec..4baa3bdc 100644 --- a/src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs +++ b/src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs @@ -174,7 +174,14 @@ internal sealed class HeadlessCollisionNeighborhood RuntimePhysicsState 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 = LandblockPhysicsContentBuilder.BuildTerrainSurface( landblock, @@ -197,12 +204,11 @@ internal sealed class HeadlessCollisionNeighborhood cache, collisions); - RuntimeCollisionAdmission admission = - physics.BeginCollisionAdmission(landblockId); try { - physics.AdmitCollisionAssets( + physics.StageCollisionAssets( admission, + prepared, new RuntimeLandblockCollisionAssets( landblockId, terrain, @@ -213,12 +219,27 @@ internal sealed class HeadlessCollisionNeighborhood currentCellId)); _ = LandblockPhysicsContentBuilder .PublishStaticCollision( - physics.Engine, + prepared.Engine, cache, landblock, collisions, 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)); } catch diff --git a/src/AcDream.Runtime/AcDream.Runtime.csproj b/src/AcDream.Runtime/AcDream.Runtime.csproj index 4c033569..b1bc35a5 100644 --- a/src/AcDream.Runtime/AcDream.Runtime.csproj +++ b/src/AcDream.Runtime/AcDream.Runtime.csproj @@ -12,6 +12,8 @@ + + diff --git a/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs b/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs index 3b04be34..ba56b41b 100644 --- a/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs +++ b/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs @@ -54,7 +54,7 @@ public sealed class RuntimeCollisionAdmission } internal RuntimePhysicsState Owner { get; } - internal bool AssetsAdmitted { get; set; } + internal bool AssetsPrepared { get; set; } internal bool Completed { get; set; } public uint LandblockId { get; } public ulong Generation { get; } @@ -63,7 +63,109 @@ public sealed class RuntimeCollisionAdmission public readonly record struct RuntimeCollisionAcknowledgement( uint LandblockId, 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); + +/// +/// 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. +/// +internal sealed class PreparedLandblockCollisionGeneration : IDisposable +{ + private readonly RuntimePhysicsState _owner; + private readonly RuntimeCollisionAdmission _admission; + private readonly Dictionary _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(); + internal uint[] SetupIds { get; private set; } = Array.Empty(); + internal IReadOnlyDictionary 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)); + } +} /// /// Presentation-free mutable physics world for one Runtime/session owner. @@ -83,9 +185,12 @@ public sealed class RuntimePhysicsState : IDisposable private readonly Dictionary _collisionGenerations = new(); private readonly Dictionary _collisionAdmissions = new(); + private int _collisionMutationThreadId; private bool _disposed; public event Action? CellCommitted; + public event Action? + CollisionGenerationCommitted; internal RuntimePhysicsState( RuntimeEntityDirectory entities, @@ -865,6 +970,7 @@ public sealed class RuntimePhysicsState : IDisposable uint landblockId) { EnsureNotDisposed(); + EnsureCollisionMutationThread(); uint canonical = CanonicalLandblock(landblockId); ulong generation = _collisionGenerations.TryGetValue( canonical, @@ -880,11 +986,29 @@ public sealed class RuntimePhysicsState : IDisposable 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, + PreparedLandblockCollisionGeneration prepared, RuntimeLandblockCollisionAssets assets) { ValidateAdmission(admission); + EnsureCollisionMutationThread(); + ValidatePreparedGeneration(admission, prepared); ArgumentNullException.ThrowIfNull(assets); if (CanonicalLandblock(assets.LandblockId) != admission.LandblockId) @@ -898,13 +1022,13 @@ public sealed class RuntimePhysicsState : IDisposable throw new InvalidOperationException( "A completed collision admission cannot publish more assets."); } - if (admission.AssetsAdmitted) + if (admission.AssetsPrepared) { 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, assets.Terrain, assets.CellSurfaces, @@ -914,38 +1038,106 @@ public sealed class RuntimePhysicsState : IDisposable if ((assets.CurrentCellId & 0xFFFF0000u) == (admission.LandblockId & 0xFFFF0000u)) { - Engine.UpdatePlayerCurrCell(assets.CurrentCellId); + prepared.Engine.UpdatePlayerCurrCell(assets.CurrentCellId); } - admission.AssetsAdmitted = true; + admission.AssetsPrepared = true; } - public RuntimeCollisionAcknowledgement CompleteCollisionAdmission( - RuntimeCollisionAdmission admission) + internal uint[] CaptureCollisionDynamicOwners( + RuntimeCollisionAdmission admission, + PreparedLandblockCollisionGeneration prepared) { 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( - "Collision admission cannot complete before its assets publish."); + "Collision generation cannot commit before its assets are prepared."); } if (admission.Completed) { 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; _collisionAdmissions.Remove(admission.LandblockId); - return new RuntimeCollisionAcknowledgement( + prepared.MarkCommitted(); + var acknowledgement = new RuntimeCollisionAcknowledgement( admission.LandblockId, 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()); } public RuntimeCollisionAcknowledgement DemoteCollisionToTerrain( uint landblockId) { EnsureNotDisposed(); + EnsureCollisionMutationThread(); uint canonical = CanonicalLandblock(landblockId); bool resident = Engine.IsLandblockTerrainResident(canonical); InvalidateCollisionAdmission(canonical); @@ -953,13 +1145,15 @@ public sealed class RuntimePhysicsState : IDisposable return new RuntimeCollisionAcknowledgement( canonical, _collisionGenerations[canonical], - resident); + resident, + Ready: Engine.IsLandblockTerrainResident(canonical)); } public RuntimeCollisionAcknowledgement WithdrawCollision( uint landblockId) { EnsureNotDisposed(); + EnsureCollisionMutationThread(); uint canonical = CanonicalLandblock(landblockId); bool resident = Engine.IsLandblockTerrainResident(canonical); InvalidateCollisionAdmission(canonical); @@ -967,7 +1161,8 @@ public sealed class RuntimePhysicsState : IDisposable return new RuntimeCollisionAcknowledgement( canonical, _collisionGenerations[canonical], - resident); + resident, + Ready: false); } public void Dispose() @@ -981,6 +1176,7 @@ public sealed class RuntimePhysicsState : IDisposable _collisionAdmissions.Clear(); _collisionGenerations.Clear(); CellCommitted = null; + CollisionGenerationCommitted = null; _disposed = true; } @@ -1053,6 +1249,20 @@ public sealed class RuntimePhysicsState : IDisposable private void EnsureNotDisposed() => 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) { 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(); + foreach (Delegate observer in observers) + { + try + { + ((Action)observer)(committed); + } + catch (Exception error) + { + System.Diagnostics.Trace.TraceError( + "Collision-generation commit observer failed after activation: {0}", + error); + } + } + } + private void InvalidateCollisionAdmission(uint landblockId) { ulong generation = _collisionGenerations.TryGetValue( diff --git a/tests/AcDream.App.Tests/Runtime/RuntimePhysicsOwnershipTests.cs b/tests/AcDream.App.Tests/Runtime/RuntimePhysicsOwnershipTests.cs index 8d49d6b2..25e3ee4e 100644 --- a/tests/AcDream.App.Tests/Runtime/RuntimePhysicsOwnershipTests.cs +++ b/tests/AcDream.App.Tests/Runtime/RuntimePhysicsOwnershipTests.cs @@ -119,7 +119,11 @@ public sealed class RuntimePhysicsOwnershipTests collisionPublisher, StringComparison.Ordinal); Assert.Contains( - "AdmitCollisionAssets(", + "StageCollisionAssets(", + collisionPublisher, + StringComparison.Ordinal); + Assert.Contains( + "CommitCollisionGeneration(", collisionPublisher, StringComparison.Ordinal); Assert.DoesNotContain( diff --git a/tests/AcDream.App.Tests/Streaming/LandblockPhysicsPublisherTests.cs b/tests/AcDream.App.Tests/Streaming/LandblockPhysicsPublisherTests.cs index b05880e0..c29f2c04 100644 --- a/tests/AcDream.App.Tests/Streaming/LandblockPhysicsPublisherTests.cs +++ b/tests/AcDream.App.Tests/Streaming/LandblockPhysicsPublisherTests.cs @@ -88,6 +88,11 @@ public sealed class LandblockPhysicsPublisherTests Assert.Equal(1, diagnostics.CellSurfaceCount); Assert.Equal(1, diagnostics.PortalPlaneCount); 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)); BuildingPhysics building = Assert.Single( fixture.Cache.BuildingIds.Select(id => fixture.Cache.GetBuilding(id)!)); @@ -502,6 +507,11 @@ public sealed class LandblockPhysicsPublisherTests LandblockPhysicsPublication secondReceipt = Begin( fixture.Publisher, 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); Assert.Equal(1, fixture.Engine.LandblockCount); @@ -513,6 +523,27 @@ public sealed class LandblockPhysicsPublisherTests 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(() => + fixture.Publisher.CompletePublication(pending)); + } + [Fact] public void NearReapply_RemovesOmittedStaticAcrossSeamAndPreservesNeighborOwner() { @@ -760,6 +791,17 @@ public sealed class LandblockPhysicsPublisherTests Assert.DoesNotContain("ShadowObjects.RefloodLandblock", source, StringComparison.Ordinal); Assert.DoesNotContain("_physicsEngine.DemoteLandblockToTerrain", 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( diff --git a/tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsStateTests.cs b/tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsStateTests.cs index 795e25c4..75ce462f 100644 --- a/tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsStateTests.cs +++ b/tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsStateTests.cs @@ -311,27 +311,28 @@ public sealed class RuntimePhysicsStateTests first.Physics.BeginCollisionAdmission(0xA9B4FFFFu); Assert.Throws(() => - first.Physics.AdmitCollisionAssets( - admission, - CollisionAssets(0xA9B4FFFFu))); + first.Physics.PrepareCollisionGeneration(admission)); Assert.Throws(() => - second.Physics.AdmitCollisionAssets( - newer, - CollisionAssets(0xA9B4FFFFu))); + second.Physics.PrepareCollisionGeneration(newer)); - first.Physics.AdmitCollisionAssets( + using PreparedLandblockCollisionGeneration prepared = + first.Physics.PrepareCollisionGeneration(newer); + first.Physics.StageCollisionAssets( newer, + prepared, CollisionAssets(0xA9B4FFFFu)); - RuntimeCollisionAcknowledgement completed = - first.Physics.CompleteCollisionAdmission(newer); + RuntimeCollisionGenerationCommit commit = + first.Physics.CommitCollisionGeneration(newer, prepared); + RuntimeCollisionAcknowledgement completed = commit.Acknowledgement; + Assert.True(commit.Committed); Assert.True(completed.WasResident); Assert.Equal(1, first.Physics.Engine.LandblockCount); Assert.Equal( 0, first.Physics.CaptureOwnership().CollisionAdmissionCount); Assert.Throws(() => - first.Physics.CompleteCollisionAdmission(newer)); + first.Physics.CommitCollisionGeneration(newer, prepared)); RuntimeCollisionAcknowledgement withdrawn = first.Physics.WithdrawCollision(0xA9B4FFFFu); @@ -340,6 +341,193 @@ public sealed class RuntimePhysicsStateTests 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(() => + 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] public void TerminalDisposalClearsLandblocksShadowsAndWorksets() { @@ -352,10 +540,13 @@ public sealed class RuntimePhysicsStateTests RuntimeCollisionAdmission admission = lifetime.Physics.BeginCollisionAdmission(0x0101FFFFu); - lifetime.Physics.AdmitCollisionAssets( + using PreparedLandblockCollisionGeneration prepared = + lifetime.Physics.PrepareCollisionGeneration(admission); + lifetime.Physics.StageCollisionAssets( admission, + prepared, CollisionAssets(0x0101FFFFu)); - _ = lifetime.Physics.CompleteCollisionAdmission(admission); + _ = lifetime.Physics.CommitCollisionGeneration(admission, prepared); lifetime.Physics.Engine.ShadowObjects.Register( entityId: record.LocalEntityId!.Value, gfxObjId: 0x01000001u, @@ -740,10 +931,12 @@ public sealed class RuntimePhysicsStateTests } private static RuntimeLandblockCollisionAssets CollisionAssets( - uint landblockId) + uint landblockId, + float terrainHeight = 0f) { var heights = new byte[81]; var table = new float[256]; + table[0] = terrainHeight; return new RuntimeLandblockCollisionAssets( landblockId, new TerrainSurface(heights, table),