From afbd241016f2288ef14ff3c93083b5984a36614f Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 2 Sep 2026 23:02:47 +0200 Subject: [PATCH] feat(physics): S2 chunk 3 - one flood per registration feeds collision rows and render entries ShadowObjectRegistry computes the CELLARRAY once per registration under Contract A (cylsphere route from the Setup's collision cylspheres, else the bbox route over the whole visual part array) and derives BOTH products from it: the collision rows (_entityToCells/_cells, CShadowObj per cell via add_shadows_to_cells @0x00514ae0) and the per-part render entries (AddPartsShadow @0x00517e40). The second, independent collision flood is gone. A caller that supplies no part array floods from its collision shapes exactly as before, so every legacy expectation holds byte-for-byte. The staged SetPosition pipeline now carries the retail part array, cell array, route, and entries through TryCaptureOwnerState/InstallOwnerState and publishes them beside the collision cell replacements, honoring the keep-when-empty rule (SetPositionInternal num_cells gate, pc:283540) for both products together; two new tests pin a cross-cell move and the keep-when-empty case. Behavior change, retail-exact: an object with decorative non-BSP parts now has its collision shapes registered in every cell those parts reach (pinned by a two-cell fixture); all-BSP objects are unchanged. Movement paths still take collision cells from the transition and recompute the retail product separately; chunk 4 unifies them on the transition's array as retail does. No particle emitter reaches this registry. Gates (implementer's isolated worktree at identical content): Release build 0/0; Core 4,961/4,961; App hermetic 6,760/6,760; collision/InstalledDat fixtures 63/63; Runtime 1,884/1,884; Content 213/213. Co-Authored-By: Claude Fable 5.1 --- docs/architecture/worldbuilder-inventory.md | 50 ++- .../s2-membership-ownership-map.md | 99 ++++- .../Physics/ShadowObjectRegistry.cs | 397 +++++++++++++++--- ...hadowObjectRegistryRetailCellArrayTests.cs | 204 +++++++++ 4 files changed, 665 insertions(+), 85 deletions(-) diff --git a/docs/architecture/worldbuilder-inventory.md b/docs/architecture/worldbuilder-inventory.md index 3180232b..f0db23e0 100644 --- a/docs/architecture/worldbuilder-inventory.md +++ b/docs/architecture/worldbuilder-inventory.md @@ -177,27 +177,35 @@ draws its complete constructed shell. Portal polygons are not GPU mesh clips; pixel occlusion comes from the ordered walk and depth buffer. **Retail static render-shadow cell membership (2026-08-31; ownership moved -to `ShadowObjectRegistry` by Campaign OVERHAUL S2 chunk 2).** Indoor statics -are not rendered solely from their authored parent EnvCell. Retail's -`CEnvCell::init_static_objects` creates a `CPhysicsObj` for every static, -then `calc_cross_cells_static` + `CPartArray::AddPartsShadow` places every -visual part in each crossed cell's `shadow_part_list`, including decorative -GfxObjs that have no collision BSP. `ShadowObjectRegistry` owns this product -as a retained per-entity retail CELLARRAY (`TryGetRetailCellArray`) plus -per-cell part entries (`GetRetailPartEntriesInCell`), computed once at the -SAME registration transaction as the collision flood -(`RegisterMultiPart`/`Register`'s optional `partArray:` parameter) — not -rebuilt per frame. `WalkProductionWorldData`'s indoor and outdoor static -sweeps BORROW that array whole per record; they no longer recompute -membership from each MeshRef's prepared visual AABB (the deleted -`ResolveStaticRenderCells` / `ShadowObjectRegistry.ComputeStaticRenderCells` -pair). The retail CELLARRAY remains distinct from `ShadowObjectRegistry`'s -own collision rows (`GetOwnerCells`/`_entityToCells`) — the two retail lists -share transit math but have different membership (collision cutover to the -same CELLARRAY is S2 chunk 3) — until then, a record whose registry entry has -no retail array yet (a streaming-window race between the static-projection -journal and the physics publisher) falls back to its authored parent cell -alone, counted by `WalkProductionWorldData.UnregisteredStaticRenderFallbackCount`. +to `ShadowObjectRegistry` by Campaign OVERHAUL S2 chunk 2; collision cutover +by S2 chunk 3, 2026-09-02).** Indoor statics are not rendered solely from +their authored parent EnvCell. Retail's `CEnvCell::init_static_objects` +creates a `CPhysicsObj` for every static, then `calc_cross_cells_static` + +`CPartArray::AddPartsShadow` places every visual part in each crossed cell's +`shadow_part_list`, including decorative GfxObjs that have no collision BSP — +one CELLARRAY flood feeds BOTH the collision `shadow_object_list` and the +render `shadow_part_list` (Contract B). `ShadowObjectRegistry` now mirrors +this exactly: `RegisterMultiPart`/`Register`'s optional `partArray:` +parameter, when supplied (every production call site since chunk 1b), drives +ONE Contract A flood (`ComputeContractACellArray`) whose result becomes BOTH +`GetOwnerCells`/`_entityToCells`/`_cells` (collision) and the retained +per-entity retail CELLARRAY (`TryGetRetailCellArray`) plus per-cell part +entries (`GetRetailPartEntriesInCell`) — not two independent floods, and not +rebuilt per frame. A caller that supplies no part array (only test/legacy +call sites remain) keeps the pre-chunk-3 collision-only dispatch untouched. +`WalkProductionWorldData`'s indoor and outdoor static sweeps BORROW the +retail array whole per record; they no longer recompute membership from each +MeshRef's prepared visual AABB (the deleted `ResolveStaticRenderCells` / +`ShadowObjectRegistry.ComputeStaticRenderCells` pair). A structural +consequence: an object mixing a colliding BSP part with a decorative +non-BSP part now gets a collision row for its BSP part in every cell the +combined CELLARRAY reaches, including cells the BSP part alone would not +have crossed — membership is decided by the whole-part-array flood, not per +part; the BSP polygon test still decides actual contact at query time. A +record whose registry entry has no retail array yet (a streaming-window race +between the static-projection journal and the physics publisher) falls back +to its authored parent cell alone, counted by +`WalkProductionWorldData.UnregisteredStaticRenderFallbackCount`. A primitive-only Setup may reuse its already-authored cylsphere collision cell set; BSP-bearing and pure-visual statics use the all-visual-part box walk. The Facility Hub stair Setup `0x02000623` is the installed-DAT diff --git a/docs/research/2026-09-01-overhaul/s2-membership-ownership-map.md b/docs/research/2026-09-01-overhaul/s2-membership-ownership-map.md index 0ab3b891..f2f6f23d 100644 --- a/docs/research/2026-09-01-overhaul/s2-membership-ownership-map.md +++ b/docs/research/2026-09-01-overhaul/s2-membership-ownership-map.md @@ -36,9 +36,10 @@ One transaction per object (`CPhysicsObj::calc_cross_cells_static` | Owner | Inputs | Output | Runs | Consumers | |---|---|---|---|---| -| `LandblockPhysicsPublisher.PublishStaticEntity` → `ShadowObjectRegistry.RegisterMultiPart` (`LandblockPhysicsPublisher.cs:930-1079`, `ShadowObjectRegistry.cs:493-584`) | BSP parts only (`ShadowShapeBuilder.FromLandblockBspParts`) or Setup cyl/sphere fallback; `CellTransit.BuildShadowCellSetFromParts` | `_entityToCells[id]` (ordered, the CELLARRAY analog) + `_cells[cell]` `ShadowEntry` rows | once per static at publication | physics broadphase `TransitionTypes.FindObjCollisionsInCell` (`TransitionTypes.cs:3755-3779`); outdoor static render lookup reads it verbatim | -| **DELETED (chunk 2).** Was `WalkProductionWorldData.ResolveStaticRenderCells` → `ShadowObjectRegistry.ComputeStaticRenderCells`. Now: `WalkProductionWorldData.ResolveIndoorStaticRenderCells`/`ResolveOutdoorStaticRenderCells` → `ShadowObjectRegistry.TryGetRetailCellArray` (`WalkProductionWorldData.cs`, `ShadowObjectRegistry.cs:634-663`) | none (borrowed) | chunk 1's retained per-entity retail CELLARRAY, computed once at registration | every `BeginFrame` sweep of `RenderSceneIndex.IndoorCellStatic`/`OutdoorStatic` (a borrow, not a recompute) | `WalkFrameDriver.GetCellStatics`; `RetailPViewRenderer` particle owner union; unregistered fallback (indoor: authored `ParentCellId`; outdoor: `GetOwnerCells`) counted by `WalkProductionWorldData.UnregisteredStaticRenderFallbackCount` | -| `LiveEntityCollisionBuilder.Register` / `ShadowObjectRegistry.UpdatePosition` / `CommitSetPosition` (`LiveEntityCollisionBuilder.cs:149-280`, `ShadowObjectRegistry.cs:881-982`) | `ShadowShapeBuilder.FromSetup` (BSP parts, else cylspheres, else spheres) | `_entityToCells` + `_cells` | spawn and every accepted move/appearance change | physics broadphase; dynamic render lookup reads it verbatim | +| **Chunk 3 (2026-09-02).** `LandblockPhysicsPublisher.PublishStaticEntity` → `ShadowObjectRegistry.RegisterMultiPart` (`LandblockPhysicsPublisher.cs:930-1079`, `ShadowObjectRegistry.cs`, `RegisterMultiPart`/`Register`/`ComputeContractACellArray`/`PublishRetailCellArray`) | the WHOLE visual part array (`ShadowShapeBuilder.FromStaticRenderParts`, chunk 1b) drives ONE Contract A flood (cylsphere route from the collision shapes' cylinders, else bbox route over the part array); a caller with no part array (every unit-test fixture, `partArray ?? shapes`) keeps the pre-chunk-3 legacy dispatch byte-for-byte | `_entityToCells[id]`/`_cells[cell]` (collision) AND `_retailCellArrays`/`_retailPartEntriesByCell` (render) from the SAME array — the second (redundant) flood chunk 1 left running is deleted | once per static at publication | physics broadphase `TransitionTypes.FindObjCollisionsInCell` (`TransitionTypes.cs:3755-3779`); `WalkProductionWorldData` (below) | +| **DELETED (chunk 2).** Was `WalkProductionWorldData.ResolveStaticRenderCells` → `ShadowObjectRegistry.ComputeStaticRenderCells`. Now: `WalkProductionWorldData.ResolveIndoorStaticRenderCells`/`ResolveOutdoorStaticRenderCells` → `ShadowObjectRegistry.TryGetRetailCellArray` (`WalkProductionWorldData.cs`, `ShadowObjectRegistry.cs:634-663`) | none (borrowed) | chunk 1's retained per-entity retail CELLARRAY, now the SAME array chunk 3 also uses for collision | every `BeginFrame` sweep of `RenderSceneIndex.IndoorCellStatic`/`OutdoorStatic` (a borrow, not a recompute) | `WalkFrameDriver.GetCellStatics`; `RetailPViewRenderer` particle owner union; unregistered fallback (indoor: authored `ParentCellId`; outdoor: `GetOwnerCells`) counted by `WalkProductionWorldData.UnregisteredStaticRenderFallbackCount` | +| **Chunk 3 (2026-09-02).** `LiveEntityCollisionBuilder.Register` / `ShadowObjectRegistry.UpdatePosition` / `CommitSetPosition` (`LiveEntityCollisionBuilder.cs:149-280`, `ShadowObjectRegistry.cs`) | `ShadowShapeBuilder.FromSetup` (collision dispatch) + `FromSetupRenderParts` (chunk 1b's part array) → the same ONE Contract A flood as above at registration; movement (`UpdatePosition`/`CommitSetPosition`'s `Recalculate` action) forwards to the same registration entry points, so it inherits the fix. `CommitSetPosition`'s `None`/`Preserve`/`Replace` actions (`RefreshPositionRows`/`ReplacePositionRows`) are UNCHANGED — they consume an externally-decided collision cell list and separately recompute the retail product via the same Contract A primitive on every move (chunk 1's design, not a second registration flood) | `_entityToCells` + `_cells`, and (when a part array is retained) `_retailCellArrays`/`_retailPartEntriesByCell` | spawn and every accepted move/appearance change | physics broadphase; `WalkProductionWorldData` dynamic render lookup | +| **Chunk 3 (2026-09-02).** The staged `ShadowObjectRegistry.TryPrepareSetPosition`/`TryApplySetPosition` pipeline | `PreparedShadowOwnerState` now also carries `RetailPartArray`/`RetailCellIds`/`RetailRoute`/`RetailRows`; `TryCaptureOwnerState`/`InstallOwnerState` seed them so the staging registry's own `RecomputeRetailCellArrayIfPresent` call (reached through `CommitSetPosition`) no longer silently no-ops; `PrepareRetailPartEntryReplacements` diffs the staged retail rows the same way `PrepareCellReplacements` diffs collision rows | `_retailPartEntriesByCell` publishes alongside `_cells` on `TryApplySetPosition` | every live entity moving through the transactional SetPosition publication tail (Runtime) | same as direct `CommitSetPosition`, now consistent with it | | `WalkProductionWorldData.ResolveDynamicRenderCells` (`WalkProductionWorldData.cs:247-283`) | `GetOwnerCells`; for an `EquippedChild` with no cells, a 64-hop parent-chain walk | whole-record buckets | every `BeginFrame` | `WalkFrameDriver.GetCellDynamics` | | `LightManager.SelectForCell` (`LightManager.cs:594-650`) | flat `_all` list, sphere overlap | per-cell light subset | per cell drawn | lighting (S5 owns; retail selects from the current EnvCell + stab list) | | `DirectionalShadowCasterFrame` (`DirectionalShadowCasterFrame.cs:229-233`) | flat outdoor indices, no cell filter | caster list | per frame | shadow pass (S5) | @@ -49,7 +50,8 @@ Findings that shape the chunks: - The two static floods are the SAME `CellTransit` walk with DIFFERENT part inputs (BSP-only vs all visual parts). Retail uses all parts once. The render-side flood already carries retail's inputs; the collision side does - not. + not. **RESOLVED (chunk 3):** one Contract A flood now drives both, for every + registration that carries a real part array. - Render membership is whole-object per cell; retail's is every part per cell (identity retained per part, with cell frame and clip planes). - Children are inherited only on the render side, at bucket time; retail @@ -237,3 +239,92 @@ from its centered position. The scan was corrected to key on a different Neftet-landblock GfxObj (`0x010046EB`) genuinely near the block's Y=192 edge, producing 2 of 6 retail cells in the neighboring landblock `0x8765`. + +## 6. Chunk 3 evidence (2026-09-02) + +Chunk 3 deletes the second flood chunk 1 left running beside the collision +one. `RegisterMultiPart`/`Register` now compute the CELLARRAY exactly once +via the shared `ComputeContractACellArray` primitive (Contract A: cylsphere +route from the collision shapes' cylinders, else the bbox route over the +part array) whenever a caller supplies a real part array — production always +does, since chunk 1b threaded `partArray:` into every `RegisterMultiPart` +call site. `_entityToCells`/`_cells` (collision, built from the actual +collision shapes) and `_retailCellArrays`/`_retailPartEntriesByCell` (render, +built from the part array) both read off that one array via +`PublishRetailCellArray`. A caller with no part array (every existing unit +test, and any future legacy caller) keeps the exact pre-chunk-3 dispatch — +`partArray ?? shapes` degrades to the old hasBsp-branch flood over `shapes` +alone — so no existing expectation for such a caller changed; the full +`Lane!=Timing&Lane!=Linux` Core suite (4,957 tests before chunk 3, 4,961 +after adding chunk-3 coverage) needed zero test-expectation edits. + +**Behavior change, exactly as predicted in §5:** an object whose part array +mixes a colliding BSP part with a decorative non-BSP-colliding visual part +now gets a `ShadowEntry` collision row for its BSP part in EVERY cell the +CELLARRAY reaches — including cells the BSP part does not geometrically +occupy, because membership (which cells hold a row at all) is decided by the +whole-array flood (Contract A), not per part. The BSP polygon test still +decides actual contact at query time (`TransitionTypes.FindObjCollisionsInCell` +never changed); only the candidate set widens. Objects whose part array is +entirely BSP-bearing are UNCHANGED (chunk 1 evidence: the bbox route over the +collision shapes and the bbox route over the identical part array agree +exactly, so old-collision and new-collision compute the same set either way) +— `tests/AcDream.App.Tests/Physics/RetailCellArrayComparatorInstalledDatTests.cs` +still reports retail == collision for all five installed-DAT fixtures, now as +a structural guarantee (both read the same array) rather than an incidental +agreement. The synthetic pin for the mixed case is +`ShadowObjectRegistryRetailCellArrayTests.RegisterMultiPart_DecorativePartArrayEntry_WidensCollisionRowsToo`. + +**`b3b7d922` prune removal, re-verified:** the bbox route +(`CellTransit.BuildShadowCellSetFromParts`) still carries no post-flood prune +of outside cells, matching Contract A's bbox branch exactly (`§1` above: "the +outdoor expansion... no later rule that removes the outside cells it just +added"). Chunk 3 does not reintroduce one; the collision cut over inherits +the same unpruned array the render side already used. + +**The staged pipeline (item B, the chunk-1 flagged gap):** `TryPrepareSetPosition` +built a scratch `staging` registry via `InstallOwnerState`, then ran the +CommitSetPosition suffix on it — but `InstallOwnerState` never seeded +`_entityRetailPartArrays`/`_retailCellArrays`/`_retailCellArrayRoutes`/ +`_retailPartEntriesByCell`, so staging's own internal +`RecomputeRetailCellArrayIfPresent` call (reached through +`RefreshPositionRows`/`ReplacePositionRows`) found nothing retained and +no-opped every time — the retail product silently never traveled through a +live-entity SetPosition commit. `PreparedShadowOwnerState` now also carries +`RetailPartArray`/`RetailCellIds`/`RetailRoute`/`RetailRows`; +`TryCaptureOwnerState` captures them from the live registry, +`InstallOwnerState` seeds them into staging, and a new +`PrepareRetailPartEntryReplacements` (parallel to `PrepareCellReplacements`) +diffs the staged retail rows against the live `_retailPartEntriesByCell` so +`TryApplySetPosition` can publish them alongside the collision cell +replacements it already applies. `LandblockReplacementBuilder`, +`MirrorOwnerFrom`, and `RefreshRetainedOwnerFrom` all route through the same +`TryCaptureOwnerState`/`InstallOwnerState` pair, so they inherit the fix for +free without their own changes. Pinned by +`ShadowObjectRegistryRetailCellArrayTests.StagedSetPosition_WithRetainedPartArray_MovesBothProductsTogether` +and `..._KeepWhenEmpty_PreservesBothProductsTogether`. + +**Scope decision — movement (`RefreshPositionRows`/`ReplacePositionRows`) is +NOT unified with collision's cell list.** Item A's title is "one flood per +REGISTRATION"; `ReplacePositionRows` receives an externally-decided collision +cell list from the Transition/physics engine (a different subsystem, not +`ComputeContractACellArray`) and, unchanged since chunk 1, separately +recomputes the retail product via `RecomputeRetailCellArrayIfPresent` on +every move. This is not the "two floods disagreeing over the same input" +problem chunk 3 targets (registration, where BOTH products used to flood the +SAME `shapes`/`partArray` independently) — the transition-computed cross-cell +list and Contract A's part-array flood are legitimately different +computations over different inputs, and collapsing them into one would be an +architecture change beyond this chunk's contract. Flagged here as an open +question for the chunk 3 retail review, not resolved unilaterally. + +**Particles (item D):** no production code path sets the `PARTICLE_EMITTER_PS` +state bit (`0x1000`) when calling `Register`/`RegisterMultiPart` today, and +particle emitters do not register with `ShadowObjectRegistry` at all — +`AcDream.App/Rendering/ParticleRenderer.cs` owns their placement independently +(`_meshReferences.Register(emitter.Handle, gfxObjId)`, a GPU mesh-reference +table, not a shadow-cell registration). There is no `(state & 0x1000) != 0` +branch to implement in this registry today; the retail +`add_particle_shadow_to_cell` own-cell-only rule remains chunk 4 (or later) +territory, to be implemented if/when particle emitters are ever routed +through this registry. diff --git a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs index 4c6802eb..59a2564f 100644 --- a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs +++ b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs @@ -450,7 +450,36 @@ public sealed class ShadowObjectRegistry return; _entityRetailPartArrays[entityId] = partArray; + (IReadOnlyList cellArray, RetailCellArrayRoute route) = ComputeContractACellArray( + seedCellId, entityWorldPos, entityWorldRot, state, collisionShapes, partArray, isStatic); + PublishRetailCellArray(entityId, cellArray, route, partArray); + } + /// + /// Campaign OVERHAUL S2 chunk 3: retail's calc_cross_cells_static + /// (0x00515160) dispatch + flood, PURE — no registry state is read or + /// written. Cylsphere route + /// (CObjCell::find_cell_list 0x0052b9f0) when the cached + /// HAS_PHYSICS_BSP_PS state bit (0x10000) is clear AND + /// carries at least one Cylinder shape + /// (Contract A's GetNumCylsphere() != 0); otherwise the bbox route + /// (CPhysicsObj::find_bbox_cell_list 0x00510fc0) over + /// . Chunk 3 calls this ONCE per registration + /// and uses the SAME result for both the collision CELLARRAY + /// (_entityToCells/_cells) and the retail render product + /// (_retailCellArrays/_retailPartEntriesByCell) — see + /// and — instead of + /// each product running its own independent flood. + /// + private (IReadOnlyList Cells, RetailCellArrayRoute Route) ComputeContractACellArray( + uint seedCellId, + Vector3 worldPos, + Quaternion worldRot, + uint state, + IReadOnlyList collisionShapes, + IReadOnlyList partArray, + bool isStatic) + { // Contract A: GetNumCylsphere() reads the Setup's authored CylSpheres, // which acdream carries as the Cylinder shapes of the COLLISION // dispatch — never as visual parts. @@ -465,30 +494,47 @@ public sealed class ShadowObjectRegistry } bool cylsphereRoute = (state & 0x10000u) == 0u && hasCylsphere; - IReadOnlyList cellArray; - RetailCellArrayRoute route; if (cylsphereRoute) { - route = RetailCellArrayRoute.Cylsphere; List cylSpheres = - BuildFloodSpheres(entityWorldPos, entityWorldRot, collisionShapes); - cellArray = CellTransit.BuildShadowCellSet( + BuildFloodSpheres(worldPos, worldRot, collisionShapes); + IReadOnlyList cells = CellTransit.BuildShadowCellSet( FloodCache, seedCellId, cylSpheres, cylSpheres.Count, isStatic); + return (cells, RetailCellArrayRoute.Cylsphere); } else { - route = RetailCellArrayRoute.BoundingBox; List boxes = - BuildFloodPartBoxes(entityWorldPos, entityWorldRot, partArray); + BuildFloodPartBoxes(worldPos, worldRot, partArray); List spheres = - BuildBspPartSpheres(entityWorldPos, entityWorldRot, partArray); - cellArray = CellTransit.BuildShadowCellSetFromParts( + BuildBspPartSpheres(worldPos, worldRot, partArray); + IReadOnlyList cells = CellTransit.BuildShadowCellSetFromParts( FloodCache, seedCellId, boxes, spheres, isStatic); + return (cells, RetailCellArrayRoute.BoundingBox); } + } - // The route is recorded even when the flood came back empty — the - // dispatch decision was still made (mirrors GetRetailCellArrayRoute - // being independently observable from cell-list non-emptiness). + /// + /// Publishes the retail render product for + /// from an ALREADY-COMPUTED cell array ( + /// or a caller's own equivalent flood) — the second half of chunk 3's + /// "one flood, two products" registration recipe. Clears any previously + /// published rows for this entity first, mirroring + /// remove_shadows_from_cells symmetry for this side product. The + /// route is recorded even when is empty — + /// the dispatch decision was still made. + /// + private void PublishRetailCellArray( + uint entityId, + IReadOnlyList cellArray, + RetailCellArrayRoute route, + IReadOnlyList partArray) + { + if (_retailCellArrays.TryGetValue(entityId, out List? previousCells)) + { + RemoveRetailPartEntriesFromCells(entityId, previousCells); + _retailCellArrays.Remove(entityId); + } _retailCellArrayRoutes[entityId] = route; if (cellArray.Count == 0) return; @@ -685,12 +731,39 @@ public sealed class ShadowObjectRegistry : DeriveOutdoorSeed(worldPos, worldOffsetX, worldOffsetY, landblockId); if (seed == 0u) return; - var spheres = new[] + bool hasRetailPartArray = partArray is not null && partArray.Count != 0; + + IReadOnlyList cellSet; + RetailCellArrayRoute retailRoute = RetailCellArrayRoute.None; + if (hasRetailPartArray) { - new DatReaderWriter.Types.Sphere { Origin = worldPos, Radius = radius }, - }; - var cellSet = CellTransit.BuildShadowCellSet( - FloodCache, seed, spheres, spheres.Length, isStatic); + // Campaign OVERHAUL S2 chunk 3: ONE flood, computed via Contract A + // (calc_cross_cells_static 0x00515160), drives BOTH the collision + // CELLARRAY and the retail render product — replacing the + // separate legacy flood below AND the second flood + // RecomputeRetailCellArray used to run independently. + IReadOnlyList collisionShapes = + collisionType == ShadowCollisionType.Cylinder + ? new[] + { + ShadowShape.Cylinder( + gfxObjId, Vector3.Zero, Quaternion.Identity, scale, radius, cylHeight), + } + : Array.Empty(); + (cellSet, retailRoute) = ComputeContractACellArray( + seed, worldPos, rotation, state, collisionShapes, partArray!, isStatic); + } + else + { + // No visual part array supplied (tests, legacy) — the single-shape + // sphere flood, byte-identical to before chunk 3. + var spheres = new[] + { + new DatReaderWriter.Types.Sphere { Origin = worldPos, Radius = radius }, + }; + cellSet = CellTransit.BuildShadowCellSet( + FloodCache, seed, spheres, spheres.Length, isStatic); + } if (cellSet.Count == 0) return; DeregisterCore(entityId, publishMutation: false); @@ -714,20 +787,13 @@ public sealed class ShadowObjectRegistry else RefreshOwnerPrefixIndex(entityId); - // Campaign OVERHAUL S2 chunk 1: an untouched side product when no + // Campaign OVERHAUL S2 chunk 1/3: an untouched side product when no // caller supplies a part array (every call site before chunk 1b). - if (partArray is not null) + // Publishes the SAME cell array computed above — no second flood. + if (hasRetailPartArray) { - IReadOnlyList collisionShapes = - collisionType == ShadowCollisionType.Cylinder - ? new[] - { - ShadowShape.Cylinder( - gfxObjId, Vector3.Zero, Quaternion.Identity, scale, radius, cylHeight), - } - : Array.Empty(); - RecomputeRetailCellArray( - entityId, seed, worldPos, rotation, state, collisionShapes, partArray, isStatic); + _entityRetailPartArrays[entityId] = partArray!; + PublishRetailCellArray(entityId, cellSet, retailRoute, partArray!); } } @@ -787,34 +853,57 @@ public sealed class ShadowObjectRegistry : DeriveOutdoorSeed(entityWorldPos, worldOffsetX, worldOffsetY, landblockId); if (seed == 0u) return; - // Retail's exclusive dispatch, mirrored: CPartArray::CacheHasPhysicsBSP - // (0x00518110) ORs 0x10000 on the first part whose gfxobj carries a - // physics BSP, and calc_cross_cells (0x00515285) branches on that bit. - // AP-152 made shape emission BSP-exclusive, so "has a BSP shape" and - // "is a BSP object" coincide exactly as the cached retail flag does. - bool hasBsp = false; - for (int i = 0; i < shapes.Count; i++) - { - if (shapes[i].CollisionType == ShadowCollisionType.BSP) - { - hasBsp = true; - break; - } - } + bool hasRetailPartArray = partArray is not null && partArray.Count != 0; IReadOnlyList cellSet; - if (hasBsp) + RetailCellArrayRoute retailRoute = RetailCellArrayRoute.None; + if (hasRetailPartArray) { - var partBoxes = BuildFloodPartBoxes(entityWorldPos, entityWorldRot, shapes); - var partSpheres = BuildBspPartSpheres(entityWorldPos, entityWorldRot, shapes); - cellSet = CellTransit.BuildShadowCellSetFromParts( - FloodCache, seed, partBoxes, partSpheres, isStatic); + // Campaign OVERHAUL S2 chunk 3: ONE flood, computed via Contract A + // (calc_cross_cells_static 0x00515160) over the WHOLE visual part + // array, drives BOTH the collision CELLARRAY and the retail + // render product — replacing the collision-only hasBsp dispatch + // below AND the second flood RecomputeRetailCellArray used to run + // independently. The route TEST still reads collision shapes + // (`shapes`) only (Contract A: "route from the collision shapes' + // cylspheres"), but the bbox-route flood itself always uses the + // wider `partArray`, so a decorative non-BSP part now widens + // collision membership too (Contract B / item E). + (cellSet, retailRoute) = ComputeContractACellArray( + seed, entityWorldPos, entityWorldRot, state, shapes, partArray!, isStatic); } else { - var floodSpheres = BuildFloodSpheres(entityWorldPos, entityWorldRot, shapes); - cellSet = CellTransit.BuildShadowCellSet( - FloodCache, seed, floodSpheres, floodSpheres.Count, isStatic); + // No visual part array supplied (tests, legacy) — retail's + // exclusive dispatch mirrored exactly as before chunk 3, BYTE- + // IDENTICAL: CPartArray::CacheHasPhysicsBSP (0x00518110) ORs + // 0x10000 on the first part whose gfxobj carries a physics BSP, + // and calc_cross_cells (0x00515285) branches on that bit. AP-152 + // made shape emission BSP-exclusive, so "has a BSP shape" and "is + // a BSP object" coincide exactly as the cached retail flag does. + bool hasBsp = false; + for (int i = 0; i < shapes.Count; i++) + { + if (shapes[i].CollisionType == ShadowCollisionType.BSP) + { + hasBsp = true; + break; + } + } + + if (hasBsp) + { + var partBoxes = BuildFloodPartBoxes(entityWorldPos, entityWorldRot, shapes); + var partSpheres = BuildBspPartSpheres(entityWorldPos, entityWorldRot, shapes); + cellSet = CellTransit.BuildShadowCellSetFromParts( + FloodCache, seed, partBoxes, partSpheres, isStatic); + } + else + { + var floodSpheres = BuildFloodSpheres(entityWorldPos, entityWorldRot, shapes); + cellSet = CellTransit.BuildShadowCellSet( + FloodCache, seed, floodSpheres, floodSpheres.Count, isStatic); + } } if (cellSet.Count == 0) return; @@ -859,15 +948,16 @@ public sealed class ShadowObjectRegistry else RefreshOwnerPrefixIndex(entityId); - // Campaign OVERHAUL S2 chunk 1: an untouched side product when no + // Campaign OVERHAUL S2 chunk 1/3: an untouched side product when no // caller supplies a part array (every call site before chunk 1b). + // Publishes the SAME cell array computed above — no second flood. // Deliberately independent of `shapes`/hasBsp above: retail's // per-part render membership (AddPartsShadow) walks EVERY visual // part, not just the BSP-only collision dispatch AP-152 emits. - if (partArray is not null) + if (hasRetailPartArray) { - RecomputeRetailCellArray( - entityId, seed, entityWorldPos, entityWorldRot, state, shapes, partArray, isStatic); + _entityRetailPartArrays[entityId] = partArray!; + PublishRetailCellArray(entityId, cellSet, retailRoute, partArray!); } } @@ -1313,12 +1403,21 @@ public sealed class ShadowObjectRegistry PreparedShadowCellReplacement[] CellReplacements, PreparedShadowPrefixReplacement[] PrefixReplacements, HashSet? OwnerPrefixes, - uint[] ChangedPrefixes); + uint[] ChangedPrefixes, + // Campaign OVERHAUL S2 chunk 3: the retail render product's diffed + // cell rows, parallel to CellReplacements but keyed on the entity's + // RETAIL cell array — see PrepareRetailPartEntryReplacements. + PreparedShadowRetailCellReplacement[] RetailCellReplacements); internal sealed record PreparedShadowCellReplacement( uint CellId, List Entries); + /// Retail part-entry analog of . + internal sealed record PreparedShadowRetailCellReplacement( + uint CellId, + List Entries); + internal sealed record PreparedShadowPrefixReplacement( uint Prefix, bool Remove, @@ -1379,7 +1478,8 @@ public sealed class ShadowObjectRegistry CellReplacements: [], PrefixReplacements: [], OwnerPrefixes: null, - ChangedPrefixes: Array.Empty()); + ChangedPrefixes: Array.Empty(), + RetailCellReplacements: []); return _mutationRevision == expectedMutation && GetOwnerVersion(entityId) == expectedOwner && !HasLogicalOwner(entityId); @@ -1414,6 +1514,8 @@ public sealed class ShadowObjectRegistry uint[] changedPrefixes = CaptureChangedPrefixes(source, replacement); PreparedShadowCellReplacement[] cellReplacements = PrepareCellReplacements(entityId, source, replacement); + PreparedShadowRetailCellReplacement[] retailCellReplacements = + PrepareRetailPartEntryReplacements(entityId, source, replacement); HashSet replacementPrefixes = CapturePrefixes(replacement); PreparedShadowPrefixReplacement[] prefixReplacements = PreparePrefixReplacements( @@ -1443,6 +1545,11 @@ public sealed class ShadowObjectRegistry _suspendedEntities.EnsureCapacity(_suspendedEntities.Count + 1); _pendingSetPositionDispatches.EnsureCapacity( _pendingSetPositionDispatches.Count + 1); + _entityRetailPartArrays.EnsureCapacity(_entityRetailPartArrays.Count + 1); + _retailCellArrays.EnsureCapacity(_retailCellArrays.Count + 1); + _retailCellArrayRoutes.EnsureCapacity(_retailCellArrayRoutes.Count + 1); + _retailPartEntriesByCell.EnsureCapacity( + _retailPartEntriesByCell.Count + retailCellReplacements.Length); prepared = new PreparedSetPositionShadowCommit( checked(++_nextPreparedSetPositionCommitId), @@ -1456,7 +1563,8 @@ public sealed class ShadowObjectRegistry cellReplacements, prefixReplacements, replacementPrefixes, - changedPrefixes); + changedPrefixes, + retailCellReplacements); return _mutationRevision == expectedMutation && GetOwnerVersion(entityId) == expectedOwner && HasLogicalOwner(entityId); @@ -1504,6 +1612,12 @@ public sealed class ShadowObjectRegistry prepared.CellReplacements[index]; _cells[replacement.CellId] = replacement.Entries; } + for (int index = 0; index < prepared.RetailCellReplacements.Length; index++) + { + PreparedShadowRetailCellReplacement replacement = + prepared.RetailCellReplacements[index]; + _retailPartEntriesByCell[replacement.CellId] = replacement.Entries; + } PreparedShadowOwnerState state = prepared.OwnerState; _entityReg[prepared.EntityId] = state.Registration; ReplaceOwnerValue(_entityShapes, prepared.EntityId, state.Shapes); @@ -1523,6 +1637,23 @@ public sealed class ShadowObjectRegistry _entityToCells, prepared.EntityId, state.CellIds); + // Campaign OVERHAUL S2 chunk 3: publish the staged retail render + // product alongside collision — the same fields PublishRetailCellArray + // maintains on the direct (non-staged) path. + if (state.RetailPartArray is not null) + { + _entityRetailPartArrays[prepared.EntityId] = state.RetailPartArray; + _retailCellArrayRoutes[prepared.EntityId] = state.RetailRoute; + } + else + { + _entityRetailPartArrays.Remove(prepared.EntityId); + _retailCellArrayRoutes.Remove(prepared.EntityId); + } + ReplaceOwnerValue( + _retailCellArrays, + prepared.EntityId, + state.RetailCellIds); if (prepared.OwnerPrefixes is not null) _ownerPrefixes[prepared.EntityId] = prepared.OwnerPrefixes; for (int index = 0; index < prepared.PrefixReplacements.Length; index++) @@ -1743,6 +1874,73 @@ public sealed class ShadowObjectRegistry return result; } + /// + /// Retail part-entry analog of — + /// diffs /'s retail rows + /// against the LIVE so + /// can publish the staged retail + /// product with the same retained-others-plus-owner's-new-rows recipe + /// the collision cells use. Keyed on retail cell ids (from + /// /RetailRows), + /// which need not equal — + /// the two products are independent floods for callers with no retained + /// part array, and coincide only once chunk 3's single-flood + /// registrations are the sole populators. + /// + private PreparedShadowRetailCellReplacement[] PrepareRetailPartEntryReplacements( + uint entityId, + PreparedShadowOwnerState before, + PreparedShadowOwnerState after) + { + var touched = new HashSet(); + AddCells(touched, before.RetailCellIds); + AddCells(touched, after.RetailCellIds); + var afterRows = new Dictionary(); + for (int index = 0; index < after.RetailRows.Count; index++) + { + PreparedShadowRetailPartRows row = after.RetailRows[index]; + touched.Add(row.CellId); + afterRows[row.CellId] = row.Entries; + } + for (int index = 0; index < before.RetailRows.Count; index++) + touched.Add(before.RetailRows[index].CellId); + + uint[] ordered = touched.ToArray(); + Array.Sort(ordered); + var result = new PreparedShadowRetailCellReplacement[ordered.Length]; + for (int index = 0; index < ordered.Length; index++) + { + uint cellId = ordered[index]; + _retailPartEntriesByCell.TryGetValue(cellId, out List? active); + afterRows.TryGetValue(cellId, out RetailPartEntry[]? ownerRows); + int retainedCount = 0; + if (active is not null) + { + for (int row = 0; row < active.Count; row++) + { + if (active[row].EntityId != entityId) + retainedCount++; + } + } + var replacement = new List( + retainedCount + (ownerRows?.Length ?? 0)); + if (active is not null) + { + for (int row = 0; row < active.Count; row++) + { + if (active[row].EntityId != entityId) + replacement.Add(active[row]); + } + } + if (ownerRows is not null) + replacement.AddRange(ownerRows); + result[index] = new PreparedShadowRetailCellReplacement( + cellId, + replacement); + } + return result; + } + private PreparedShadowPrefixReplacement[] PreparePrefixReplacements( uint entityId, HashSet before, @@ -2879,6 +3077,37 @@ public sealed class ShadowObjectRegistry } } } + + // Campaign OVERHAUL S2 chunk 3: capture the retail render product + // alongside the collision rows so the staging registry (InstallOwnerState) + // can recompute it faithfully, and so TryApplySetPosition can publish + // whatever the staged CommitSetPosition produced. + _entityRetailPartArrays.TryGetValue( + entityId, + out IReadOnlyList? retailPartArray); + _retailCellArrays.TryGetValue(entityId, out List? retailCells); + RetailCellArrayRoute retailRoute = _retailCellArrayRoutes.TryGetValue( + entityId, + out RetailCellArrayRoute capturedRoute) + ? capturedRoute + : RetailCellArrayRoute.None; + var retailRows = new List(); + if (retailCells is not null) + { + foreach (uint cellId in retailCells) + { + if (_retailPartEntriesByCell.TryGetValue( + cellId, + out List? entries)) + { + retailRows.Add(new PreparedShadowRetailPartRows( + cellId, + entries.Where(entry => entry.EntityId == entityId) + .ToArray())); + } + } + } + state = new PreparedShadowOwnerState( entityId, registration, @@ -2887,7 +3116,11 @@ public sealed class ShadowObjectRegistry rows, _suspendedEntities.Contains(entityId), suspendedCells is null ? null : new List(suspendedCells), - withdrawn is null ? null : new HashSet(withdrawn)); + withdrawn is null ? null : new HashSet(withdrawn), + retailPartArray, + retailCells is null ? null : new List(retailCells), + retailRoute, + retailRows); return true; } @@ -2912,6 +3145,32 @@ public sealed class ShadowObjectRegistry for (int entryIndex = 0; entryIndex < row.Entries.Length; entryIndex++) AddEntryToCell(row.Entries[entryIndex], row.CellId); } + + // Campaign OVERHAUL S2 chunk 3: seed the retail render product too — + // without this, a staging registry's own internal Contract-A + // recompute (RecomputeRetailCellArrayIfPresent, reached through + // CommitSetPosition) finds nothing retained and silently no-ops, + // dropping the retail product for every entity that moves through + // the staged TryPrepareSetPosition/TryApplySetPosition pipeline. + if (state.RetailPartArray is not null) + { + _entityRetailPartArrays[state.EntityId] = state.RetailPartArray; + _retailCellArrayRoutes[state.EntityId] = state.RetailRoute; + } + if (state.RetailCellIds is not null) + _retailCellArrays[state.EntityId] = state.RetailCellIds; + for (int rowIndex = 0; rowIndex < state.RetailRows.Count; rowIndex++) + { + PreparedShadowRetailPartRows row = state.RetailRows[rowIndex]; + if (!_retailPartEntriesByCell.TryGetValue( + row.CellId, + out List? entries)) + { + entries = new List(); + _retailPartEntriesByCell[row.CellId] = entries; + } + entries.AddRange(row.Entries); + } BumpOwnerVersion(state.EntityId); } @@ -3112,12 +3371,30 @@ public sealed class ShadowObjectRegistry IReadOnlyList Rows, bool Suspended, List? SuspendedCellIds, - HashSet? WithdrawnPrefixes); + HashSet? WithdrawnPrefixes, + // Campaign OVERHAUL S2 chunk 3: the retail render product + // (RecomputeRetailCellArray's retained side product) travels with + // the owner state so a live entity moving through + // TryPrepareSetPosition/TryApplySetPosition keeps both products + // consistent with the direct CommitSetPosition path — the chunk-1 + // gap where the staging registry never saw these fields, so its own + // internal Contract-A recompute silently no-opped. + IReadOnlyList? RetailPartArray, + List? RetailCellIds, + RetailCellArrayRoute RetailRoute, + IReadOnlyList RetailRows); internal sealed record PreparedShadowCellRows( uint CellId, ShadowEntry[] Entries); + /// Retail part-entry analog of + /// for one cell of 's + /// backing store. + internal sealed record PreparedShadowRetailPartRows( + uint CellId, + RetailPartEntry[] Entries); + /// /// Retires the complete logical registry at terminal physics-engine /// disposal, including suspended live registrations that own no cell row. diff --git a/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryRetailCellArrayTests.cs b/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryRetailCellArrayTests.cs index c96e2287..c2858c95 100644 --- a/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryRetailCellArrayTests.cs +++ b/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryRetailCellArrayTests.cs @@ -360,4 +360,208 @@ public class ShadowObjectRegistryRetailCellArrayTests Assert.Equal(0x7003u, entries[1].GfxObjId); Assert.DoesNotContain(entries, e => e.GfxObjId == 0x7001u); } + + // ------------------------------------------------------------------- + // Campaign OVERHAUL S2 chunk 3: one flood per registration. Both + // _entityToCells/_cells (collision) and the retail render product now + // come from the SAME CELLARRAY when a caller supplies partArray — a + // decorative non-BSP-colliding visual part widens COLLISION membership + // too, not just the retail product. The BSP test still decides contact + // at query time; only membership (which cells hold a row at all) widens. + // ------------------------------------------------------------------- + + [Fact] + public void RegisterMultiPart_DecorativePartArrayEntry_WidensCollisionRowsToo() + { + var reg = new ShadowObjectRegistry(); + const uint entityId = 0x20u; + const uint collidingGfxObj = 0xAAAA0001u; + const uint decorativeGfxObj = 0xBBBB0001u; + + // The entity's actual COLLISION geometry is a single BSP part at the + // entity's own origin (CellA only, on its own — AP-152's exclusive + // collision dispatch). + IReadOnlyList collisionShapes = new[] { Bsp(collidingGfxObj) }; + // Its WHOLE VISUAL part array (chunk 1b's partArray input) additionally + // carries a decorative part 30 m away with no collision of its own. + // ShadowShapeBuilder.FromStaticRenderParts/FromSetupRenderParts tag + // EVERY visual part CollisionType.BSP as a pure geometry carrier + // regardless of whether its GfxObj has a real physics BSP, so this + // mirrors production exactly. + IReadOnlyList partArray = new[] + { + Bsp(collidingGfxObj), + Bsp(decorativeGfxObj, new Vector3(30f, 0f, 0f)), + }; + + reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, collisionShapes, + state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId, + partArray: partArray); + + // Retail CELLARRAY reaches both cells — the decorative part's box + // pulls in CellB even though it cannot collide. + Assert.True(reg.TryGetRetailCellArray(entityId, out var retailCells)); + Assert.Equal(new[] { CellA, CellB }, retailCells); + Assert.Equal(RetailCellArrayRoute.BoundingBox, reg.GetRetailCellArrayRoute(entityId)); + + // Chunk 3: the SAME array now drives collision. Retail == collision + // by construction, not coincidence. + Assert.Equal(retailCells, reg.GetOwnerCells(entityId)); + + foreach (uint cellId in new[] { CellA, CellB }) + { + var collisionRows = reg.GetObjectsInCell(cellId) + .Where(e => e.EntityId == entityId) + .ToList(); + Assert.Single(collisionRows); + Assert.Equal(collidingGfxObj, collisionRows[0].GfxObjId); + + // The decorative part never appears as a COLLISION row (AP-152: + // the collision dispatch stays BSP-exclusive over collisionShapes) + // — only as a retail render part entry. + Assert.DoesNotContain(collisionRows, e => e.GfxObjId == decorativeGfxObj); + + var partEntries = reg.GetRetailPartEntriesInCell(cellId) + .Where(e => e.EntityId == entityId) + .Select(e => e.GfxObjId) + .ToList(); + Assert.Contains(collidingGfxObj, partEntries); + Assert.Contains(decorativeGfxObj, partEntries); + } + } + + [Fact] + public void RegisterMultiPart_WithoutDecorativeParts_RetailArrayStillEqualsCollisionCells() + { + // BSP-only shapes with partArray == shapes: retail array and + // collision cells must agree exactly, whether or not a partArray is + // supplied at all (item F: "registering with partArray and without + // must produce identical _entityToCells for BSP-only shapes"). + var withPartArray = new ShadowObjectRegistry(); + var withoutPartArray = new ShadowObjectRegistry(); + const uint entityId = 0x21u; + IReadOnlyList shapes = new[] { Bsp(0x0100_0055u, radius: 2f) }; + + withPartArray.RegisterMultiPart(entityId, Pos, Quaternion.Identity, shapes, + state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId, + seedCellId: CellA, isStatic: true, partArray: shapes); + withoutPartArray.RegisterMultiPart(entityId, Pos, Quaternion.Identity, shapes, + state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId, + seedCellId: CellA, isStatic: true); + + Assert.Equal( + withoutPartArray.GetOwnerCells(entityId), + withPartArray.GetOwnerCells(entityId)); + Assert.True(withPartArray.TryGetRetailCellArray(entityId, out var retailCells)); + Assert.Equal(withPartArray.GetOwnerCells(entityId), retailCells); + } + + // ------------------------------------------------------------------- + // Campaign OVERHAUL S2 chunk 3 item B: the staged + // TryPrepareSetPosition/TryApplySetPosition pipeline must carry the + // retail render product exactly like the direct CommitSetPosition path + // — the chunk-1 gap where InstallOwnerState never seeded the staging + // registry's retail dictionaries, so its own internal Contract-A + // recompute silently no-opped and the applied commit dropped the retail + // product on the floor. + // ------------------------------------------------------------------- + + [Fact] + public void StagedSetPosition_WithRetainedPartArray_MovesBothProductsTogether() + { + var reg = new ShadowObjectRegistry(); + const uint entityId = 0x30u; + IReadOnlyList parts = new[] { Cyl(0x8001u) }; + + reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, parts, + state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId, partArray: parts); + Assert.True(reg.TryGetRetailCellArray(entityId, out var before)); + Assert.Equal(new[] { CellA }, before); + Assert.Equal(new[] { CellA }, reg.GetOwnerCells(entityId)); + + var moved = new Vector3(42f, 12f, 50f); // 30 m away in X -> CellB + Assert.True(reg.TryPrepareSetPosition( + entityId, + moved, + Quaternion.Identity, + seedCellId: CellB, + worldOffsetX: OffX, + worldOffsetY: OffY, + PhysicsShadowCommitAction.Replace, + crossCellIds: [CellB], + provenShapeless: false, + suspendOwner: false, + out var prepared)); + + // Not yet applied — both products still describe the OLD position. + Assert.Equal(new[] { CellA }, reg.GetOwnerCells(entityId)); + Assert.True(reg.TryGetRetailCellArray(entityId, out var stillBefore)); + Assert.Equal(new[] { CellA }, stillBefore); + + Assert.True(reg.TryApplySetPosition(prepared!, out var receipt)); + Assert.True(receipt.Mutated); + + Assert.Equal(new[] { CellB }, reg.GetOwnerCells(entityId)); + Assert.True(reg.TryGetRetailCellArray(entityId, out var after)); + Assert.Equal(new[] { CellB }, after); + Assert.Equal(RetailCellArrayRoute.Cylsphere, reg.GetRetailCellArrayRoute(entityId)); + + var partEntries = reg.GetRetailPartEntriesInCell(CellB) + .Where(e => e.EntityId == entityId).ToList(); + Assert.Single(partEntries); + Assert.Equal(0x8001u, partEntries[0].GfxObjId); + Assert.DoesNotContain( + reg.GetRetailPartEntriesInCell(CellA), e => e.EntityId == entityId); + + var collisionRows = reg.GetObjectsInCell(CellB) + .Where(e => e.EntityId == entityId).ToList(); + Assert.Single(collisionRows); + Assert.Equal(0x8001u, collisionRows[0].GfxObjId); + Assert.DoesNotContain( + reg.GetObjectsInCell(CellA), e => e.EntityId == entityId); + } + + [Fact] + public void StagedSetPosition_KeepWhenEmpty_PreservesBothProductsTogether() + { + var reg = new ShadowObjectRegistry(); + const uint entityId = 0x31u; + IReadOnlyList parts = new[] { Cyl(0x8101u) }; + + reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, parts, + state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId, partArray: parts); + Assert.True(reg.TryGetRetailCellArray(entityId, out var before)); + Assert.Equal(new[] { CellA }, before); + Assert.Equal(new[] { CellA }, reg.GetOwnerCells(entityId)); + + // seedCellId: 0 with the Recalculate action resolves NO seed at all + // (landblockId derives from seedCellId, so it is also 0 — + // DeriveOutdoorSeed's own guard) — retail keeps the previous + // shadows (SetPositionInternal num_cells gate, pc:283540). The + // staged pipeline must honor this for BOTH products, not just + // collision. + Assert.True(reg.TryPrepareSetPosition( + entityId, + new Vector3(999f, 999f, 50f), + Quaternion.Identity, + seedCellId: 0u, + worldOffsetX: OffX, + worldOffsetY: OffY, + PhysicsShadowCommitAction.Recalculate, + crossCellIds: ImmutableArray.Empty, + provenShapeless: false, + suspendOwner: false, + out var prepared)); + Assert.True(reg.TryApplySetPosition(prepared!, out _)); + + Assert.Equal(new[] { CellA }, reg.GetOwnerCells(entityId)); + Assert.True(reg.TryGetRetailCellArray(entityId, out var after)); + Assert.Equal(new[] { CellA }, after); + Assert.Equal(RetailCellArrayRoute.Cylsphere, reg.GetRetailCellArrayRoute(entityId)); + + var partEntries = reg.GetRetailPartEntriesInCell(CellA) + .Where(e => e.EntityId == entityId).ToList(); + Assert.Single(partEntries); + Assert.Equal(0x8101u, partEntries[0].GfxObjId); + } }