diff --git a/docs/architecture/worldbuilder-inventory.md b/docs/architecture/worldbuilder-inventory.md
index f0db23e0..8e36eb46 100644
--- a/docs/architecture/worldbuilder-inventory.md
+++ b/docs/architecture/worldbuilder-inventory.md
@@ -212,27 +212,58 @@ walk. The Facility Hub stair Setup `0x02000623` is the installed-DAT
regression: its authored parent is `0x8A02015F`, while its visual parts also
register in `0x8A02015E` and the adjoining vertical cells.
+**Moving multipart objects and equipped children (S2 chunk 4, 2026-09-02).**
Moving multipart objects follow the sibling retail route rather than their
authored/feet cell alone: `CPhysicsObj::add_shadows_to_cells` installs every
`CPartArray` part in every cell of the object's retained `CELLARRAY`, and
`CPhysicsPart::Draw` stamps each part only after that part passes a cell's
-portal test. `WalkProductionWorldData` therefore buckets live projections by
-all `ShadowObjectRegistry.GetOwnerCells` rows, while the walk classifier owns
-a projection+part drawn-pass stamp. Retail advances that stamp after
+portal test. `WalkProductionWorldData.ResolveDynamicRenderCells` therefore
+reads `ShadowObjectRegistry.TryGetRetailCellArray` directly for every dynamic
+record — the SAME borrow-not-recompute recipe the static sweeps already use
+— falling back to `GetOwnerCells` (counted by the shared
+`UnregisteredStaticRenderFallbackCount`) only when the registry has not
+registered an array yet, while the walk classifier owns a separate
+projection+part drawn-pass stamp. Retail advances that stamp after
`LScape::draw` + `FlushAlphaList` and before the interior depth clear
(`PView::DrawCells` @0x005A4886), so a part may draw once in the landscape
pass and once again in the post-clear interior-cell pass; acdream re-arms the
-classifier at that exact walk boundary. `add_shadows_to_cells` recursively
-passes the root's same `CELLARRAY` to every attached child, so equipped-child render
-projections resolve their accepted parent chain to the root's owner cells;
-they do not fall back to one authored cell. Never replace the per-part stamp
-with a whole-projection drawn-once gate: at a stair portal the torso may pass
-in the first cell while head/hair fails and must retry through another crossed
+classifier at that exact walk boundary. Never replace the per-part stamp with
+a whole-projection drawn-once gate: at a stair portal the torso may pass in
+the first cell while head/hair fails and must retry through another crossed
cell. One binding exception is the local player: retail
`RenderDeviceD3D::DrawMeshInternal` bypasses `Get/SetDrawnThisFrame` when
`CPhysicsPart::IsPartOfPlayerObj` is true, repainting the player's parts at
every crossed-cell turn so later wall/depth ordering remains correct.
+Retail's move-path product is the SAME `add_shadows_to_cells` call the
+registration path uses, not a second independent flood:
+`CPhysicsObj::SetPositionInternal` (0x00515330) takes the transition's own
+`cell_array` on a successful move and calls
+`remove_shadows_from_cells`/`add_shadows_to_cells` with it directly — the
+`calc_cross_cells_static` cylsphere/bbox dispatch never re-runs on a move.
+`ShadowObjectRegistry.ReplacePositionRows` mirrors this exactly:
+`PublishRetailProductFromExactCells` publishes the retail render product from
+the SAME exact cell list collision just republished into, replacing the
+independent Contract A recompute an earlier chunk ran on every move; retail's
+`num_cells > 0` keep-when-empty gate covers both products identically, so a
+move with no resolvable transition array leaves both untouched.
+
+`add_shadows_to_cells` also recursively passes the root's same `CELLARRAY` to
+every object in `children` (Contract B); acdream's attached projections
+(equipped weapons/shields/ammunition) own no independent collision shapes, so
+`ShadowObjectRegistry.AttachChild`/`DetachChild` publish PART ENTRIES only —
+never a collision row — into every cell of the resolved root's current retail
+CELLARRAY, re-publishing automatically whenever that array changes
+(registration, move, staged apply, `ReplaceMultiPartPayload`).
+`EquippedChildRenderController.TryRealize` calls `AttachChild` at the exact
+moment an attached projection's `WorldEntity` is registered against its
+accepted parent; `CommitProjectionRemoval` (the withdrawal/unparent/teardown
+funnel) calls `DetachChild`. A nested attachment (a child of a child)
+resolves to the ultimate root by walking the existing attach chain at the
+registry, bounded and cycle-safe — `WalkProductionWorldData` no longer walks
+an accepted-parent chain of its own; it reads the child's own id straight off
+`TryGetRetailCellArray`, identically to any other dynamic record.
+
**Slice I3 prepared collision extension (2026-07-25).** At its introduction,
the package remained format 1 and retained mesh type values 1–3; bake-tool 4
appended typed GfxObj,
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 f2f6f23d..536fa011 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
@@ -38,9 +38,10 @@ One transaction per object (`CPhysicsObj::calc_cross_cells_static`
|---|---|---|---|---|
| **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` |
+| **Chunk 4 (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. **RESOLVED (chunk 4):** `CommitSetPosition`'s `None`/`Preserve`/`Replace` actions (`RefreshPositionRows`/`ReplacePositionRows`) now publish the retail render product from the SAME exact collision cell list the move just used (`PublishRetailProductFromExactCells`) — the independent Contract A recompute chunk 1/3 ran on every move (`RecomputeRetailCellArrayIfPresent`) is deleted. Mirrors retail `SetPositionInternal`'s `add_shadows_to_cells(this, &arg2->cell_array)` (pc:283536-283537), which consumes the transition's own array for both products in one call; the `num_cells > 0` keep-when-empty gate (pc:283540) now covers both products identically | `_entityToCells` + `_cells`, and (when a part array is retained) `_retailCellArrays`/`_retailPartEntriesByCell`, both from the SAME exact cell list on every move | spawn and every accepted move/appearance change | physics broadphase; `WalkProductionWorldData` dynamic render lookup (now a direct `TryGetRetailCellArray` read, see below) |
+| **Chunk 4 (2026-09-02).** The staged `ShadowObjectRegistry.TryPrepareSetPosition`/`TryApplySetPosition` pipeline | `PreparedShadowOwnerState` carries `RetailPartArray`/`RetailCellIds`/`RetailRoute`/`RetailRows`; `TryCaptureOwnerState`/`InstallOwnerState` seed them so the staging registry's own `CommitSetPosition` call inherits chunk 4's one-array-two-products move path for free; `PrepareRetailPartEntryReplacements` diffs the staged retail rows the same way `PrepareCellReplacements` diffs collision rows; `TryApplySetPosition` additionally calls `RepublishAttachedChildren` on the LIVE registry after publishing, since the staging registry carries none of the live attach-chain state | `_retailPartEntriesByCell` publishes alongside `_cells` on `TryApplySetPosition`; every attached child of the moved root republishes too | every live entity moving through the transactional SetPosition publication tail (Runtime) | same as direct `CommitSetPosition`, now consistent with it |
+| **Chunk 4 (2026-09-02).** `ShadowObjectRegistry.AttachChild`/`DetachChild` (`ShadowObjectRegistry.cs`) — retail's `add_shadows_to_cells` child-inheritance recursion (Contract B) | `EquippedChildRenderController.TryRealize` calls `AttachChild(childEntityId, rootEntityId, childPartArray)` at the exact moment the attached projection's `WorldEntity` is registered against its accepted parent, using `ShadowShapeBuilder.FromSetupRenderParts` over the SAME `PhysicsDataCache` resolvers `LiveEntityCollisionBuilder` uses; `CommitProjectionRemoval` (the single withdrawal/unparent/teardown funnel) calls `DetachChild`. A nested attachment (child of a child) resolves to the ultimate root by walking the existing chain, bounded and cycle-safe | new `_childParent`/`_parentChildren`/`_childPartArrays` dictionaries (own `CollisionWorldState` fields, reset with everything else); PART ENTRIES only for the child — never a collision row, since acdream's attached projections own no independent collision shapes | attach/detach, and every time the root's array republishes (registration, move, staged apply, `ReplaceMultiPartPayload`) | `WalkProductionWorldData.ResolveDynamicRenderCells` (a plain `TryGetRetailCellArray` read on the CHILD's own id — no parent walk) |
+| **RESOLVED (chunk 4).** `WalkProductionWorldData.ResolveDynamicRenderCells` (`WalkProductionWorldData.cs`) | `ShadowObjectRegistry.TryGetRetailCellArray` directly, for every dynamic record including equipped children (their array is published at attach time by the registry, above); unregistered fallback is `GetOwnerCells`, counted by the SAME `UnregisteredStaticRenderFallbackCount` the static buckets use — the render-side 64-hop parent-chain walk and its `FindParentLocalId` plumbing into this class are deleted | 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) |
| Topology: `EnvCellLandblockBuildBuilder.BuildVisibilityCell` vs `WalkCellFactory.FromParsed` vs `FlatEnvCellTopology` | same DAT / pak | three distinct portal graphs (`LoadedCell`, `WalkCell`, flat asset) | per landblock build | render, walk, `CellTransit` |
@@ -54,8 +55,12 @@ Findings that shape the chunks:
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
- inherits at registration.
+- **RESOLVED (chunk 4).** Children were inherited only on the render side, at
+ bucket time (a 64-hop parent-chain walk); retail inherits at registration
+ (`add_shadows_to_cells`'s recursion through `children`). Now
+ `ShadowObjectRegistry.AttachChild` owns the inheritance at the registry, at
+ attach time, and re-propagates on every root array change; the render-side
+ walk is deleted.
- `b3b7d922` removed the indoor-seed stab-list prune inside the shared
`BuildShadowCellSetFromParts`, affecting every caller. Retail has no such
prune in the bbox route (Contract A), so the removal is the retail
@@ -106,9 +111,11 @@ gates: Core physics suites, the placement/collision replay fixtures, and the
connected nine-stop route; `CellTransit`'s prune removal is confirmed
against Contract A.
-**Chunk 4 — dynamics and children.** `add_shadows_to_cells` recursion:
-children register with the root's CELLARRAY at registration and movement;
-particles own-cell only; the render-side parent-chain walk is deleted.
+**Chunk 4 (landed 2026-09-02) — dynamics and children.** `add_shadows_to_cells`
+recursion: children register with the root's CELLARRAY at registration and
+movement; particles own-cell only (still no production path reaches this
+registry — chunk 3's finding stands, deferred); the render-side parent-chain
+walk is deleted. See §7.
**Chunk 5 — consumer cutover and deletion.** Remaining `WalkProductionWorldData`
buckets become borrowed views; particle owner-cell union reads the same;
@@ -328,3 +335,147 @@ 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.
+
+## 7. Chunk 4 evidence (2026-09-02)
+
+**Move-path truth confirmed in the pseudo-C (item A).** `CPhysicsObj::SetPositionInternal`
+`0x00515330` (`docs/research/named-retail/acclient_2013_pseudo_c.txt` lines
+283399-283543) proves the movement product is retail's SAME
+`add_shadows_to_cells` call the registration path uses — not a second,
+independently-flooded array:
+
+```text
+if (this->cell != 0)
+{
+ if ((this->state & 0x10000) != 0)
+ {
+ CPhysicsObj::calc_cross_cells(this); // full recompute (pc:283530)
+ return 1;
+ }
+ if (arg2->cell_array.num_cells > 0) // keep-when-empty gate (pc:283534/283540)
+ {
+ CPhysicsObj::remove_shadows_from_cells(this);
+ CPhysicsObj::add_shadows_to_cells(this, &arg2->cell_array); // pc:283536-283537
+ }
+ // else: neither product is touched — both stay exactly as they were.
+}
+```
+
+`arg2` is the `CTransition` the sphere-path walk already produced; its
+`cell_array` is the SAME array retail's collision system computed during the
+move — not Contract A's `calc_cross_cells_static` dispatch (that only runs at
+registration, or via the `state & 0x10000` full-recompute branch, which
+acdream already maps to `PhysicsShadowCommitAction.Recalculate` →
+`UpdatePosition`, unaffected by this chunk). `CPhysicsObj::calc_cross_cells`
+`0x00515230` (lines 283332-283395) confirms the full-recompute branch is a
+DIFFERENT primitive (its own cylsphere/bbox dispatch, ending in the same
+`remove_shadows_from_cells` + `add_shadows_to_cells` pair) — chunk 4 leaves it
+untouched.
+
+**Chunk 4 changes, exactly as the pseudo-C requires:**
+
+- `ShadowObjectRegistry.ReplacePositionRows` now calls the new
+ `PublishRetailProductFromExactCells(entityId, exactCells)` — the SAME
+ `exactCells` list collision just republished into — instead of
+ `RecomputeRetailCellArrayIfPresent`'s independent Contract A flood.
+ `PublishRetailProductFromExactCells` is a thin wrapper over the existing
+ `PublishRetailCellArray`, reusing the entity's LAST recorded dispatch route
+ (registration never re-runs on a move, matching the pseudo-C: no
+ `calc_cross_cells_static` call anywhere in `SetPositionInternal`'s
+ non-full-recompute branch).
+- `ShadowObjectRegistry.RefreshPositionRows`'s "nothing retained at all"
+ branch (both `_entityToCells` and `_suspendedEntityCells` empty) no longer
+ calls anything — true keep-when-empty, matching the pseudo-C's `else`
+ (neither product touched).
+- `RecomputeRetailCellArrayIfPresent` and the now-unreachable
+ `RecomputeRetailCellArray` are deleted outright (S2 plan rule "no
+ competing production owner").
+- **Scope-decision reversal from chunk 3's §6 open question:** chunk 3 left
+ the move path as two legitimately-different computations (transition
+ cross-cells vs. Contract A's part-array flood) and flagged it for chunk 4's
+ retail review. The pseudo-C resolves it: retail's OWN move path never runs
+ Contract A at all; it reuses the transition's array for both products. The
+ "two computations" framing was itself the deviation from retail — chunk 4
+ removes it, not preserves it.
+- **Test consequence:** `ShadowObjectRegistryRetailCellArrayTests.CommitSetPosition_WithRetainedPartArray_RecomputesRetailCellArray`
+ encoded exactly the two-source behavior this chunk removes (a `.None`
+ commit whose retail array independently flooded to the entity's NEW world
+ position while collision stayed at the OLD retained cell). It is replaced
+ by `CommitSetPosition_NoneAction_PublishesRetailProductFromTheRetainedCells`
+ (retail array == `GetOwnerCells`, both at the retained cell) plus two new
+ fixtures covering `.Replace` (both move to the transition's cross cells)
+ and the direct-path keep-when-empty case. The new invariant, pinned across
+ every `CommitSetPosition` variant and the staged apply: **retail array ==
+ `GetOwnerCells` after every move.**
+
+**Children (item B/C), landed exactly as designed.** `ShadowObjectRegistry`
+gains `AttachChild(childEntityId, rootEntityId, childPartArray)` /
+`DetachChild(childEntityId)`, backed by three new `CollisionWorldState`
+dictionaries (`ShadowChildParent`, `ShadowParentChildren`,
+`ShadowChildPartArrays` — reset by `AttachCollisionWorld`'s state swap and by
+`Clear()` exactly like every other per-entity table). `AttachChild` publishes
+PART ENTRIES only (never a collision row — acdream's attached projections own
+no independent collision shapes) into every cell of the root's CURRENT
+`_retailCellArrays` entry; a nested attachment (child of a child) resolves to
+the ultimate root by walking the existing `_childParent` chain, bounded at 64
+hops and rejecting a cycle before any state mutates. `RepublishAttachedChildren`
+is called from every site that publishes a root's retail cell array —
+`PublishRetailCellArray` (covers registration and, via
+`PublishRetailProductFromExactCells`, movement), `TryApplySetPosition`
+(staged apply, since the staging registry that computed the prepared commit
+carries none of the live registry's attach-chain state), and
+`ReplaceMultiPartPayload` (defensive completeness; the root's cell SET never
+actually changes there, so this is a structural no-op in practice) — and
+recurses depth-first through `_parentChildren` in attach order, matching
+`add_shadows_to_cells`'s exact recursion through `children`. `Deregister`
+cascades a genuine teardown through every attached child (guarded so the
+INTERNAL "clear then re-register" idiom `Register`/`RegisterMultiPart` use
+ahead of their own flood — `DeregisterCore(..., publishMutation: false)` —
+never detaches children it should be re-publishing to instead).
+
+**App wiring (item C).** `EquippedChildRenderController.TryRealize` — the one
+seam that both `OnCreateParentAccepted` and `OnParentEvent` funnel through to
+materialize an attached projection's `WorldEntity` — calls `AttachChild`
+right after `_attachedByChild[childKey] = attached`, using a new
+`BuildChildRenderParts` helper: `ShadowShapeBuilder.FromSetupRenderParts`
+over the child's `Setup` + its post-`AnimPartChanged` `template` (already
+built by `BuildPartTemplate` for the pose composition) + the controller's own
+new `PhysicsDataCache` field — the SAME resolver pair
+`LiveEntityCollisionBuilder.Build` uses, wired from the SAME composition
+point (`d.PhysicsDataCache`, `LivePresentationComposition.cs`) that already
+supplies `LiveEntityCollisionBuilder`. `CommitProjectionRemoval` — the single
+funnel every withdrawal/unparent/teardown path in the file commits removal
+through — calls `DetachChild(child.Entity.Id)`. No Core→App dependency: both
+`ShadowObjectRegistry` and `PhysicsDataCache` are Core types the App layer
+already depends on; the controller (App) references Core, never the reverse.
+
+`WalkProductionWorldData.ResolveDynamicRenderCells` is now a direct
+`TryGetRetailCellArray` read (the same recipe `ResolveOutdoorStaticRenderCells`
+already used for statics) for EVERY dynamic record, children included — no
+special-casing on `RenderCasterIdentityKind.EquippedChild` any more, since the
+registry now answers correctly for a child on its own id. The render-side
+64-hop parent-chain walk, its `_findParentLocalId` field, and the
+`findParentLocalId` constructor parameter threaded through
+`WalkProductionWorldData` → `RetailPViewRenderer` → `FrameRootComposition`
+are deleted; `EquippedChildRenderController.FindParentLocalId` itself is
+UNCHANGED and keeps serving its other caller (`EntityEffectController`'s pose
+composition, `LivePresentationComposition.cs`). An entity with no retail
+array yet falls back to `GetOwnerCells`, counted by the SAME
+`UnregisteredStaticRenderFallbackCount` the static buckets already use (item
+C's explicit requirement — one fallback counter, not a second one for
+dynamics).
+
+**Particles (item D).** Unchanged from chunk 3's finding: no production code
+path sets the particle-emitter state bit or routes an emitter through
+`ShadowObjectRegistry` at all (`AcDream.App/Rendering/ParticleRenderer.cs`
+owns emitter placement independently via a GPU mesh-reference table). Left as
+a documented chunk-5-or-later item; this chunk invents no path for it.
+
+**Automated evidence.** Core suite `Lane!=Timing&Lane!=Linux`: 4,970/4,970 (was
+4,961; +9 new `ShadowObjectRegistryRetailCellArrayTests` fixtures — 2 replacing
+the retargeted move-path test, 7 covering `AttachChild`/`DetachChild`,
+nesting, and cycle rejection). App suite (hermetic lanes): 6,761/6,761 (was
+6,760; net +1 — the old `ResolveDynamicRenderCells_EquippedDescendantInheritsRootCellArray`
+parent-chain-walk test replaced by two direct-registry tests). Runtime suite:
+1,884/1,884 (unchanged — chunk 4 touched no Runtime source). Full solution
+Release build: 0 warnings, 0 errors.
diff --git a/src/AcDream.App/Composition/FrameRootComposition.cs b/src/AcDream.App/Composition/FrameRootComposition.cs
index 28c3199e..a1a2d5e2 100644
--- a/src/AcDream.App/Composition/FrameRootComposition.cs
+++ b/src/AcDream.App/Composition/FrameRootComposition.cs
@@ -507,8 +507,7 @@ internal sealed class FrameRootCompositionPhase
?? throw new InvalidOperationException(
"The retail frame walk requires the landscape registry."),
d.CellVisibility,
- d.PhysicsEngine.ShadowObjects,
- live.EquippedChildren.FindParentLocalId),
+ d.PhysicsEngine.ShadowObjects),
retailPViewPassExecutor,
retailPViewPassExecutor),
retailPViewCells,
diff --git a/src/AcDream.App/Composition/LivePresentationComposition.cs b/src/AcDream.App/Composition/LivePresentationComposition.cs
index 3ba05a6a..ea1ad600 100644
--- a/src/AcDream.App/Composition/LivePresentationComposition.cs
+++ b/src/AcDream.App/Composition/LivePresentationComposition.cs
@@ -622,7 +622,9 @@ internal sealed class LivePresentationCompositionPhase
childRecord,
positionVersion,
projectionVersion,
- d.PlayerIdentity.ServerGuid)),
+ d.PlayerIdentity.ServerGuid),
+ d.PhysicsEngine.ShadowObjects,
+ d.PhysicsDataCache),
static value => value.Dispose());
Fault(LivePresentationCompositionPoint.CorePresentationCreated);
diff --git a/src/AcDream.App/Rendering/EquippedChildRenderController.cs b/src/AcDream.App/Rendering/EquippedChildRenderController.cs
index b8924217..546295ea 100644
--- a/src/AcDream.App/Rendering/EquippedChildRenderController.cs
+++ b/src/AcDream.App/Rendering/EquippedChildRenderController.cs
@@ -34,6 +34,28 @@ public sealed class EquippedChildRenderController : IDisposable
_withdrawProjection;
private readonly EntityEffectPoseRegistry _poses;
+ ///
+ /// Campaign OVERHAUL S2 chunk 4: the SAME canonical shadow registry
+ /// LiveEntityCollisionBuilder publishes into — retail's
+ /// add_shadows_to_cells child-inheritance recursion (Contract B)
+ /// needs an attached child's visual part array registered against its
+ /// accepted parent's CURRENT retail CELLARRAY. Attached projections
+ /// (equipped weapons/shields/ammunition) never carry independent
+ /// collision shapes, so this owner ONLY calls
+ /// /
+ /// — never Register/RegisterMultiPart.
+ ///
+ private readonly ShadowObjectRegistry _shadows;
+
+ ///
+ /// Campaign OVERHAUL S2 chunk 4: the SAME PhysicsDataCache resolver
+ /// pair
+ /// uses for ShadowShapeBuilder.FromSetupRenderParts — a static and
+ /// a live Setup sharing a GfxObj id can never disagree about its render
+ /// geometry.
+ ///
+ private readonly PhysicsDataCache _physicsData;
+
private ParentAttachmentState Relations => _liveEntities.ParentAttachments;
/// Raised after the attached projection is fully registered.
@@ -98,7 +120,9 @@ public sealed class EquippedChildRenderController : IDisposable
EntityEffectPoseRegistry poses,
Func acceptParent,
Func
- withdrawProjection)
+ withdrawProjection,
+ ShadowObjectRegistry shadows,
+ PhysicsDataCache physicsData)
{
_dats = dats ?? throw new ArgumentNullException(nameof(dats));
_datLock = datLock ?? throw new ArgumentNullException(nameof(datLock));
@@ -108,6 +132,8 @@ public sealed class EquippedChildRenderController : IDisposable
_acceptParent = acceptParent ?? throw new ArgumentNullException(nameof(acceptParent));
_withdrawProjection = withdrawProjection
?? throw new ArgumentNullException(nameof(withdrawProjection));
+ _shadows = shadows ?? throw new ArgumentNullException(nameof(shadows));
+ _physicsData = physicsData ?? throw new ArgumentNullException(nameof(physicsData));
_parentOfAttached = static child => child.ParentRecord.ProjectionKey;
_tickAttached = TickChild;
_reconcileAttached = ReconcileChild;
@@ -659,6 +685,15 @@ public sealed class EquippedChildRenderController : IDisposable
CaptureParentPresentation(attached, parentEntity);
RuntimeEntityKey childKey = RequireProjectionKey(childRecord);
_attachedByChild[childKey] = attached;
+ // Campaign OVERHAUL S2 chunk 4: register the retail render-shadow
+ // inheritance (Contract B) at the exact moment the attached
+ // projection's WorldEntity is registered against its accepted
+ // parent — retail's add_shadows_to_cells child recursion, ported at
+ // the registry rather than reconstructed at render/bucket time.
+ _shadows.AttachChild(
+ entity.Id,
+ parentEntity.Id,
+ BuildChildRenderParts(childSetup, template, scale));
_pendingUnparentByChild.Remove(childKey);
if ((parentRecord.FinalPhysicsState & PhysicsStateFlags.Hidden) != 0)
{
@@ -1147,6 +1182,38 @@ public sealed class EquippedChildRenderController : IDisposable
return available;
}
+ ///
+ /// Campaign OVERHAUL S2 chunk 4: retail's per-part render membership
+ /// (CPartArray::AddPartsShadow 0x00517e40 walks EVERY visual
+ /// part, not just the BSP-only collision dispatch) for one attached
+ /// child, via — the
+ /// SAME builder and the SAME resolver pair
+ /// LiveEntityCollisionBuilder.Build uses for an ordinary live
+ /// Setup, so a static, a live entity, and an attached child sharing a
+ /// GfxObj id can never disagree about its render geometry.
+ /// 's per-part GfxObj identity (post-
+ /// AnimPartChanged, ) supplies
+ /// effectivePartGfxObjIds; the child's own attach-composed pose
+ /// carries no per-part local-position meaning for THIS product — retail
+ /// never re-floods to place a child, it only inherits the root's
+ /// CELLARRAY (Contract B) — so no pose override is threaded here,
+ /// matching FromSetupRenderParts's own placement-frame fallback.
+ ///
+ private IReadOnlyList BuildChildRenderParts(
+ Setup setup, IReadOnlyList template, float scale)
+ {
+ var effectiveGfxObjIds = new uint[template.Count];
+ for (int i = 0; i < template.Count; i++)
+ effectiveGfxObjIds[i] = template[i].GfxObjId;
+ return ShadowShapeBuilder.FromSetupRenderParts(
+ setup,
+ scale,
+ effectiveGfxObjIds,
+ partPoseOverride: null,
+ _physicsData.GetGfxObj,
+ _physicsData.GetVisualBounds);
+ }
+
private static PaletteOverride? BuildPaletteOverride(WorldSession.EntitySpawn spawn)
{
if (spawn.SubPalettes is not { Count: > 0 } subPalettes)
@@ -1388,6 +1455,12 @@ public sealed class EquippedChildRenderController : IDisposable
}
_attachedByChild.Remove(key);
+ // Campaign OVERHAUL S2 chunk 4: this is the single funnel every
+ // withdrawal/unparent/teardown path (WithdrawAttachedProjection,
+ // AdvanceUnparentTransition, TearDownRecordProjections, the pending-
+ // subtree captures) commits through — the exact mirror of
+ // AttachChild above.
+ _shadows.DetachChild(child.Entity.Id);
ProjectionRemoved?.Invoke(child.ChildRecord);
return true;
}
diff --git a/src/AcDream.App/Rendering/RetailPViewRenderer.cs b/src/AcDream.App/Rendering/RetailPViewRenderer.cs
index d8ceb098..7ac6580f 100644
--- a/src/AcDream.App/Rendering/RetailPViewRenderer.cs
+++ b/src/AcDream.App/Rendering/RetailPViewRenderer.cs
@@ -78,8 +78,7 @@ internal sealed class RetailPViewRenderer
Walk.WalkBuildingRegistry walkBuildings,
Walk.WalkLandscapeAssembler walkLandscape,
CellVisibility walkCellRegistry,
- ShadowObjectRegistry shadows,
- Func? findParentLocalId = null)
+ ShadowObjectRegistry shadows)
{
_renderSceneShadow = renderSceneShadow
?? throw new ArgumentNullException(nameof(renderSceneShadow));
@@ -91,8 +90,7 @@ internal sealed class RetailPViewRenderer
?? throw new ArgumentNullException(nameof(walkCellRegistry));
_walkWorldData = new Walk.WalkProductionWorldData(
_walkBuildings,
- shadows ?? throw new ArgumentNullException(nameof(shadows)),
- findParentLocalId);
+ shadows ?? throw new ArgumentNullException(nameof(shadows)));
_walkClearInteriorDepthAction = ClearWalkInteriorDepth;
_walkDrawExitSealsAction = DrawWalkExitSeals;
}
diff --git a/src/AcDream.App/Rendering/Walk/WalkProductionWorldData.cs b/src/AcDream.App/Rendering/Walk/WalkProductionWorldData.cs
index cad00897..de43a31d 100644
--- a/src/AcDream.App/Rendering/Walk/WalkProductionWorldData.cs
+++ b/src/AcDream.App/Rendering/Walk/WalkProductionWorldData.cs
@@ -78,7 +78,6 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
{
private readonly WalkBuildingRegistry _buildings;
private readonly ShadowObjectRegistry _shadows;
- private readonly Func _findParentLocalId;
private RenderSceneQuery _scene;
private uint _tupleLandblockId;
private int _renderCenterLbX;
@@ -107,17 +106,13 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
internal WalkProductionWorldData(
WalkBuildingRegistry buildings,
- ShadowObjectRegistry shadows,
- Func? findParentLocalId = null)
+ ShadowObjectRegistry shadows)
{
_buildings = buildings ?? throw new ArgumentNullException(nameof(buildings));
_shadows = shadows ?? throw new ArgumentNullException(nameof(shadows));
- _findParentLocalId = findParentLocalId ?? NoParentLocalId;
_tryGetRetailCellArray = TryGetRetailCellArrayForEntity;
}
- private static uint? NoParentLocalId(uint _) => null;
-
private (bool Found, IReadOnlyList Cells) TryGetRetailCellArrayForEntity(uint entityId)
{
bool found = _shadows.TryGetRetailCellArray(entityId, out IReadOnlyList cells);
@@ -264,8 +259,11 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
ref readonly RenderProjectionRecord record = ref _dynamicSweepScratch[i];
IReadOnlyList renderCells = ResolveDynamicRenderCells(
in record,
+ _tryGetRetailCellArray,
_shadows.GetOwnerCells,
- _findParentLocalId);
+ out bool usedFallback);
+ if (usedFallback)
+ UnregisteredStaticRenderFallbackCount++;
BucketDynamicRecord(
in record,
renderCells,
@@ -277,48 +275,39 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
}
///
- /// Retail CPhysicsObj::add_shadows_to_cells recursively passes the
- /// root object's same CELLARRAY to every child in its CHILDLIST. Attached
- /// projections deliberately own no independent collision rows in acdream,
- /// so walk their accepted parent chain until the root's retained array is
- /// found. Ordinary dynamics continue to consume only their own rows.
+ /// Campaign OVERHAUL S2 chunk 4: one dynamic record's render-cell
+ /// membership, borrowed from
+ /// — the SAME direct read
+ /// uses. An equipped child's retail array is now published by
+ /// at the registry (retail
+ /// Contract B's add_shadows_to_cells child-inheritance recursion),
+ /// so this no longer needs its own render-side parent-chain walk — the
+ /// registry already resolved a nested attachment to its ultimate root.
+ /// When the registry has no retail array yet for this entity (the same
+ /// streaming-window race
+ /// documents), the fallback is today's collision-flood
+ /// answer, counted the
+ /// same way as the static fallbacks via .
///
internal static IReadOnlyList ResolveDynamicRenderCells(
in RenderProjectionRecord record,
+ Func Cells)> tryGetRetailCellArray,
Func> getOwnerCells,
- Func findParentLocalId)
+ out bool usedFallback)
{
+ ArgumentNullException.ThrowIfNull(tryGetRetailCellArray);
ArgumentNullException.ThrowIfNull(getOwnerCells);
- ArgumentNullException.ThrowIfNull(findParentLocalId);
- uint current = record.Source.LocalEntityId;
- IReadOnlyList cells = getOwnerCells(current);
- if (cells.Count > 0
- || record.EntityPayload.CasterIdentity
- != RenderCasterIdentityKind.EquippedChild)
+ (bool found, IReadOnlyList cells) =
+ tryGetRetailCellArray(record.Source.LocalEntityId);
+ if (found)
{
+ usedFallback = false;
return cells;
}
- // ParentAttachmentState rejects cycles. Keep a hard bound here so a
- // corrupted diagnostic callback still cannot stall a render frame.
- for (int depth = 0; depth < 64; depth++)
- {
- uint? parent = findParentLocalId(current);
- if (parent is not { } parentId
- || parentId == 0u
- || parentId == current)
- {
- break;
- }
-
- current = parentId;
- cells = getOwnerCells(current);
- if (cells.Count > 0)
- return cells;
- }
-
- return Array.Empty();
+ usedFallback = true;
+ return getOwnerCells(record.Source.LocalEntityId);
}
///
diff --git a/src/AcDream.Core/Physics/CollisionWorldState.cs b/src/AcDream.Core/Physics/CollisionWorldState.cs
index 6dac2a2a..84c30c5b 100644
--- a/src/AcDream.Core/Physics/CollisionWorldState.cs
+++ b/src/AcDream.Core/Physics/CollisionWorldState.cs
@@ -127,6 +127,17 @@ internal sealed class CollisionWorldState
ShadowEntityRetailCellArrayRoutes { get; } = new();
internal Dictionary> RetailPartEntriesByCell { get; } = new();
+ // ── Campaign OVERHAUL S2 chunk 4 ────────────────────────────────────────
+ // Retail's add_shadows_to_cells (0x00514ae0) child-inheritance recursion
+ // (oh1-construction-landscape-contract.md Contract B): an attached
+ // projection (equipped weapon/shield/ammunition) owns no independent
+ // collision shapes and inherits its root's retail CELLARRAY instead of
+ // flooding its own. See ShadowObjectRegistry.AttachChild/DetachChild.
+ internal Dictionary ShadowChildParent { get; } = new();
+ internal Dictionary> ShadowParentChildren { get; } = new();
+ internal Dictionary>
+ ShadowChildPartArrays { get; } = new();
+
// ── O1 per-prefix installed-key ledgers ────────────────────────────────
// Every mutation of the five landblock-scoped world maps goes through the
// typed helpers below so these ledgers stay exact. The seal's landblock-
diff --git a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs
index 59a2564f..9557f137 100644
--- a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs
+++ b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs
@@ -89,6 +89,27 @@ public sealed class ShadowObjectRegistry
private Dictionary> _retailPartEntriesByCell =>
_collisionWorld.Current.RetailPartEntriesByCell;
+ ///
+ /// Campaign OVERHAUL S2 chunk 4: immediate parent (attachment point) for
+ /// every entity attached via — retail's
+ /// add_shadows_to_cells child-inheritance recursion (Contract B).
+ /// A nested attachment's ultimate root is resolved by walking this map;
+ /// see .
+ ///
+ private Dictionary _childParent =>
+ _collisionWorld.Current.ShadowChildParent;
+
+ /// Direct children of one attachment point, in attach (child-list)
+ /// order — the reverse index of .
+ private Dictionary> _parentChildren =>
+ _collisionWorld.Current.ShadowParentChildren;
+
+ /// Each attached child's OWN visual part array (its equipped
+ /// item's Setup parts), published into the root's cells by
+ /// — never flooded independently.
+ private Dictionary> _childPartArrays =>
+ _collisionWorld.Current.ShadowChildPartArrays;
+
///
/// BR-7: per-entity registration arguments, kept so a registration can be
/// RE-RUN when more cells hydrate. Retail's equivalent is
@@ -400,61 +421,6 @@ public sealed class ShadowObjectRegistry
private PhysicsDataCache? _fallback;
private PhysicsDataCache FloodCache => DataCache ?? _fallbackCache;
- ///
- /// Retail's exact CObjCell::find_cell_list/find_bbox_cell_list
- /// CELLARRAY (Contract A, re-verified 2026-09-01 through the live Ghidra
- /// bridge at 127.0.0.1:8081 against patchmem.gpr) for one
- /// entity's WHOLE part array, plus the sibling
- /// CPartArray::AddPartsShadow (0x00517e40) per-cell part-entry
- /// product, published into .
- ///
- ///
- /// A CHUNK-1 SIDE PRODUCT ONLY (Campaign OVERHAUL S2 chunk 1,
- /// `docs/research/2026-09-01-overhaul/s2-membership-ownership-map.md`
- /// §3): it never writes or
- /// , and nothing in production reads it yet.
- ///
- ///
- ///
- /// Route — CPhysicsObj::calc_cross_cells_static 0x00515160's
- /// branch table:
- ///
- ///
- /// - cylsphere (CObjCell::find_cell_list 0x0052b9f0, over the
- /// authored CylSpheres, reusing — "today's
- /// cylinder registration") when (state & 0x10000) == 0 AND the
- /// object's authored COLLISION dispatch (,
- /// retail CPartArray::GetNumCylsphere() != 0: the Setup's
- /// CylSpheres) carries at least one
- /// shape. The visual part array
- /// never decides the route: retail's cylspheres are Setup collision data,
- /// not parts;
- /// - otherwise the bbox route (CPhysicsObj::find_bbox_cell_list
- /// 0x00510fc0 via ) —
- /// every other case, including a BSP-bearing object (state bit set) and a
- /// part array with no authored CylSpheres at all.
- ///
- ///
- private void RecomputeRetailCellArray(
- uint entityId,
- uint seedCellId,
- Vector3 entityWorldPos,
- Quaternion entityWorldRot,
- uint state,
- IReadOnlyList collisionShapes,
- IReadOnlyList partArray,
- bool isStatic)
- {
- ClearRetailCellArray(entityId);
- if (partArray.Count == 0 || seedCellId == 0u)
- 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
@@ -523,6 +489,16 @@ public sealed class ShadowObjectRegistry
/// remove_shadows_from_cells symmetry for this side product. The
/// route is recorded even when is empty —
/// the dispatch decision was still made.
+ ///
+ ///
+ /// Campaign OVERHAUL S2 chunk 4: this is retail's ONE place a
+ /// CPhysicsObj's CELLARRAY changes, so it is also the one place
+ /// that re-runs — Contract B's
+ /// "after the root, add_shadows_to_cells recursively passes the same
+ /// CELLARRAY to every object in children". Harmless no-op on a scratch
+ /// staging registry (), which never
+ /// carries any attach relationships of its own.
+ ///
///
private void PublishRetailCellArray(
uint entityId,
@@ -537,29 +513,43 @@ public sealed class ShadowObjectRegistry
}
_retailCellArrayRoutes[entityId] = route;
if (cellArray.Count == 0)
+ {
+ RepublishAttachedChildren(entityId);
return;
+ }
var orderedCells = new List(cellArray.Count);
for (int i = 0; i < cellArray.Count; i++)
orderedCells.Add(cellArray[i]);
_retailCellArrays[entityId] = orderedCells;
PublishRetailPartEntries(entityId, orderedCells, partArray);
+ RepublishAttachedChildren(entityId);
}
///
- /// Recomputes at a new position
- /// when — and only when — a retail part array is currently retained for
- /// . No-op for every entity registered
- /// without one (item E of the S2 chunk-1 contract: existing callers stay
- /// byte-for-byte unaffected).
+ /// Campaign OVERHAUL S2 chunk 4: publishes the retail render product from
+ /// the SAME exact cell list the move-path collision republish
+ /// () just used — no second,
+ /// independently-flooded cell array. Retail's
+ /// CPhysicsObj::SetPositionInternal (pc:283399) does exactly this
+ /// on a successful transition: add_shadows_to_cells(this,
+ /// &arg2->cell_array) (pc:283536-283537) consumes the
+ /// TRANSITION's own array for BOTH the collision CShadowObj list
+ /// and the render shadow_part_list in one call — there is no
+ /// separate bbox/cylsphere dispatch on the move path
+ /// (calc_cross_cells_static only runs at registration, or at a
+ /// full recompute gated by the HAS_PHYSICS_BSP_PS state bit,
+ /// pc:283528, which acdream maps to
+ /// → , unaffected by this method).
+ /// A no-op (existing rows retained, matching retail's num_cells >
+ /// 0 gate, pc:283540) when no retail part array is retained for this
+ /// entity — item E of the S2 chunk-1 contract: a caller that never
+ /// supplied a part array stays byte-for-byte unaffected on every move
+ /// too.
///
- private void RecomputeRetailCellArrayIfPresent(
+ private void PublishRetailProductFromExactCells(
uint entityId,
- uint seedCellId,
- Vector3 worldPosition,
- Quaternion worldRotation,
- uint state,
- bool isStatic)
+ IReadOnlyList exactCells)
{
if (!_entityRetailPartArrays.TryGetValue(
entityId,
@@ -568,23 +558,23 @@ public sealed class ShadowObjectRegistry
{
return;
}
- // The retained multipart collision dispatch decides the route; a
- // single-shape registration retains none, which can only be a
- // non-cylsphere object here (cylinder singles never move).
- IReadOnlyList collisionShapes =
- _entityShapes.TryGetValue(entityId, out var retainedShapes)
- ? retainedShapes
- : Array.Empty();
- RecomputeRetailCellArray(
- entityId, seedCellId, worldPosition, worldRotation, state,
- collisionShapes, partArray, isStatic);
+ // The route is retail's calc_cross_cells_static DISPATCH decision
+ // (cylsphere vs bbox); the move path never re-runs that dispatch, so
+ // the route recorded at the last actual dispatch (registration, or a
+ // Recalculate-driven full recompute) still describes it exactly.
+ RetailCellArrayRoute route = _retailCellArrayRoutes.TryGetValue(
+ entityId,
+ out RetailCellArrayRoute existingRoute)
+ ? existingRoute
+ : RetailCellArrayRoute.None;
+ PublishRetailCellArray(entityId, exactCells, route, partArray);
}
///
/// Removes every retained retail-cell-array product for
/// : the part array, the route, the ordered
/// CELLARRAY, and every per-cell row it
- /// published. The inverse of ,
+ /// published. The inverse of ,
/// mirroring retail's remove_shadows_from_cells (0x00511230)
/// symmetry for this side product.
///
@@ -601,10 +591,12 @@ public sealed class ShadowObjectRegistry
/// Removes every owned by
/// from each of ,
- /// reclaiming a cell's list once it is left empty.
+ /// reclaiming a cell's list once it is left empty. Accepts
+ /// so an attached child can share the
+ /// root's own cell list (chunk 4) without a defensive copy.
private void RemoveRetailPartEntriesFromCells(
uint entityId,
- List cellIds)
+ IReadOnlyList cellIds)
{
for (int i = 0; i < cellIds.Count; i++)
{
@@ -629,7 +621,7 @@ public sealed class ShadowObjectRegistry
///
private void PublishRetailPartEntries(
uint entityId,
- List orderedCells,
+ IReadOnlyList orderedCells,
IReadOnlyList partArray)
{
bool clipPlanesRequired = orderedCells.Count > 1;
@@ -657,7 +649,9 @@ public sealed class ShadowObjectRegistry
///
/// The retail CELLARRAY retained for — see
- /// . Returns
+ /// (registration) and
+ /// (movement). Returns
+ ///
/// when no retail part array was ever supplied for this entity (every
/// existing caller that omits the new trailing partArray parameter
/// on /) or when the
@@ -698,6 +692,208 @@ public sealed class ShadowObjectRegistry
? route
: RetailCellArrayRoute.None;
+ ///
+ /// Hard bound on an attach chain's depth — mirrors the render-side
+ /// parent-chain walk this API replaces (Campaign OVERHAUL S2 chunk 4).
+ /// A corrupted or cyclic caller cannot stall a registration.
+ ///
+ private const int MaxAttachChainDepth = 64;
+
+ ///
+ /// Campaign OVERHAUL S2 chunk 4: retail's CPhysicsObj::add_shadows_to_cells
+ /// (0x00514ae0) child-inheritance recursion — "after the root,
+ /// add_shadows_to_cells recursively passes the same CELLARRAY to every
+ /// object in children, in child-list order"
+ /// (oh1-construction-landscape-contract.md Contract B).
+ /// acdream's attached projections (equipped weapons/shields/ammunition)
+ /// own no independent collision shapes, so this publishes PART ENTRIES
+ /// into every cell of 's CURRENT retail
+ /// CELLARRAY — never a collision row. is
+ /// the entity attaches TO; it may
+ /// itself already be an attached child, in which case the ULTIMATE root
+ /// is resolved by walking the existing attach chain (nested attachment).
+ /// Re-published automatically whenever the root's array changes
+ /// (registration, move, staged apply, )
+ /// via . A cycle or a chain deeper
+ /// than is rejected — returns
+ /// without mutating any state. Re-attaching an
+ /// already-attached child (a reparent) detaches it from its previous
+ /// parent first.
+ ///
+ public bool AttachChild(
+ uint childEntityId,
+ uint rootEntityId,
+ IReadOnlyList childPartArray)
+ {
+ ArgumentNullException.ThrowIfNull(childPartArray);
+ if (!TryValidateAttach(childEntityId, rootEntityId))
+ return false;
+
+ if (_childParent.ContainsKey(childEntityId))
+ DetachChildCore(childEntityId, removeFromParentList: true);
+
+ _childParent[childEntityId] = rootEntityId;
+ if (!_parentChildren.TryGetValue(rootEntityId, out List? siblings))
+ {
+ siblings = new List();
+ _parentChildren[rootEntityId] = siblings;
+ }
+ siblings.Add(childEntityId);
+ _childPartArrays[childEntityId] = childPartArray;
+
+ PublishChildEntries(childEntityId);
+ return true;
+ }
+
+ ///
+ /// Campaign OVERHAUL S2 chunk 4: the inverse of
+ /// — retail's remove_shadows_from_cells (0x00511230) symmetry for
+ /// one attached child. Also detaches every descendant of
+ /// (a nested child of a child), matching
+ /// retail's recursion through children on removal too. No-op
+ /// (returns ) when
+ /// is not currently attached.
+ ///
+ public bool DetachChild(uint childEntityId) =>
+ DetachChildCore(childEntityId, removeFromParentList: true);
+
+ private bool DetachChildCore(uint childEntityId, bool removeFromParentList)
+ {
+ if (!_childParent.TryGetValue(childEntityId, out uint parentId))
+ return false;
+
+ if (_parentChildren.TryGetValue(childEntityId, out List? grandchildren)
+ && grandchildren.Count > 0)
+ {
+ uint[] toDetach = grandchildren.ToArray();
+ for (int i = 0; i < toDetach.Length; i++)
+ DetachChildCore(toDetach[i], removeFromParentList: false);
+ _parentChildren.Remove(childEntityId);
+ }
+
+ if (_retailCellArrays.TryGetValue(childEntityId, out List? cells))
+ {
+ RemoveRetailPartEntriesFromCells(childEntityId, cells);
+ _retailCellArrays.Remove(childEntityId);
+ }
+ _childPartArrays.Remove(childEntityId);
+ _childParent.Remove(childEntityId);
+
+ if (removeFromParentList
+ && _parentChildren.TryGetValue(parentId, out List? siblings))
+ {
+ siblings.Remove(childEntityId);
+ if (siblings.Count == 0)
+ _parentChildren.Remove(parentId);
+ }
+ return true;
+ }
+
+ ///
+ /// Validates an call before any state
+ /// mutation: rejects a direct self-attach and any attach that would form
+ /// a cycle ( already an ancestor of
+ /// through the existing chain), and bounds
+ /// the walk at .
+ ///
+ private bool TryValidateAttach(uint childEntityId, uint parentId)
+ {
+ if (childEntityId == parentId)
+ return false;
+
+ uint current = parentId;
+ for (int depth = 0; depth < MaxAttachChainDepth; depth++)
+ {
+ if (!_childParent.TryGetValue(current, out uint next))
+ return true; // reached the current chain's root — no cycle
+ if (next == childEntityId)
+ return false; // would form a cycle
+ current = next;
+ }
+ return false; // chain too deep — defensive
+ }
+
+ ///
+ /// Walks from up
+ /// to its ultimate root (an id with no recorded parent), bounded at
+ /// . Safe to call post-attach because
+ /// already proved the chain acyclic.
+ ///
+ private uint ResolveAttachRoot(uint entityId)
+ {
+ uint current = entityId;
+ for (int depth = 0; depth < MaxAttachChainDepth; depth++)
+ {
+ if (!_childParent.TryGetValue(current, out uint parent))
+ return current;
+ current = parent;
+ }
+ return current;
+ }
+
+ ///
+ /// (Re)publishes 's retail part entries
+ /// from its resolved root's CURRENT retail cell array — never an
+ /// independent flood. Clears the child's own previously published
+ /// entries first. When the root has no published array (not yet
+ /// registered, or its own array collapsed to empty), the child's
+ /// entries are cleared too and nothing is republished — the same
+ /// keep-when-empty symmetry applies
+ /// to the root itself.
+ ///
+ private void PublishChildEntries(uint childEntityId)
+ {
+ if (_retailCellArrays.TryGetValue(childEntityId, out List? previousCells))
+ {
+ RemoveRetailPartEntriesFromCells(childEntityId, previousCells);
+ _retailCellArrays.Remove(childEntityId);
+ }
+ if (!_childPartArrays.TryGetValue(childEntityId, out IReadOnlyList? partArray))
+ return;
+
+ uint root = ResolveAttachRoot(childEntityId);
+ if (root == childEntityId
+ || !_retailCellArrays.TryGetValue(root, out List? rootCells)
+ || rootCells.Count == 0)
+ {
+ return;
+ }
+
+ // Share the root's own list reference — PublishRetailCellArray never
+ // mutates a published List in place, always replaces it
+ // wholesale, so this stays valid until the NEXT publish (which
+ // republishes this child too, see RepublishAttachedChildren).
+ _retailCellArrays[childEntityId] = rootCells;
+ PublishRetailPartEntries(childEntityId, rootCells, partArray);
+ }
+
+ ///
+ /// Re-publishes every attached child of — DIRECT
+ /// children first (in child-list/attach order), each recursively followed
+ /// by its OWN attached children, matching retail's
+ /// add_shadows_to_cells depth-first recursion through
+ /// children exactly. Called from every place a root's retail cell
+ /// array is (re)published: (covers
+ /// registration and, via ,
+ /// movement), (staged apply), and
+ /// . A no-op leaf call when
+ /// has no attached children.
+ ///
+ private void RepublishAttachedChildren(uint rootId)
+ {
+ if (!_parentChildren.TryGetValue(rootId, out List? children)
+ || children.Count == 0)
+ {
+ return;
+ }
+ for (int i = 0; i < children.Count; i++)
+ {
+ uint childId = children[i];
+ PublishChildEntries(childId);
+ RepublishAttachedChildren(childId);
+ }
+ }
+
///
/// Register a single-shape entity. is the
/// entity's m_position.objcell_id — the flood seed. Pass 0 to
@@ -1029,6 +1225,13 @@ public sealed class ShadowObjectRegistry
{
RemoveRetailPartEntriesFromCells(entityId, retailCells);
PublishRetailPartEntries(entityId, retailCells, partArray);
+ // Campaign OVERHAUL S2 chunk 4: the root's own cell SET is
+ // unchanged here (SetPart never re-floods), but item B's
+ // contract lists this call among the root-array-touching
+ // sites a child must stay consistent with — cheap and
+ // harmless since the child's cells never actually change
+ // when the root's do not.
+ RepublishAttachedChildren(entityId);
}
}
@@ -1675,6 +1878,13 @@ public sealed class ShadowObjectRegistry
_ownerVersions[prepared.EntityId] = prepared.FinalOwnerVersion;
_lastAppliedSetPositionCommitId = prepared.CommitId;
_pendingSetPositionDispatches.Add(prepared.CommitId);
+ // Campaign OVERHAUL S2 chunk 4: the staging registry that computed
+ // `prepared` carries none of THIS (live) registry's attach
+ // relationships — RepublishAttachedChildren was a no-op there. Now
+ // that the root's new retail cell array is live, propagate it to
+ // every attached child (item B: "staged apply" is one of the
+ // triggers).
+ RepublishAttachedChildren(prepared.EntityId);
receipt = new SetPositionShadowCommitReceipt(
prepared.CommitId,
prepared.EntityId,
@@ -2060,15 +2270,18 @@ public sealed class ShadowObjectRegistry
return;
}
+ // Campaign OVERHAUL S2 chunk 4: retail's keep-when-empty gate
+ // (pc:283540) reached its terminal case — no retained collision
+ // cells at all to republish from — so BOTH products stay exactly as
+ // they were. No independent retail-array recompute here any more;
+ // that was the "two floods disagreeing over the same input"
+ // duplication chunk 3's evidence (§6) flagged and chunk 4 removes.
_entityReg[entityId] = registration with
{
SeedCellId = seedCellId,
EntityWorldPos = worldPosition,
EntityWorldRot = worldRotation,
};
- RecomputeRetailCellArrayIfPresent(
- entityId, seedCellId, worldPosition, worldRotation,
- registration.State, registration.IsStatic);
BumpOwnerVersion(entityId);
}
@@ -2171,9 +2384,14 @@ public sealed class ShadowObjectRegistry
if (withdrawn.Count == 0)
_withdrawnPrefixesByOwner.Remove(entityId);
}
- RecomputeRetailCellArrayIfPresent(
- entityId, seedCellId, worldPosition, worldRotation,
- registration.State, registration.IsStatic);
+ // Campaign OVERHAUL S2 chunk 4: one array, two products. The retail
+ // render product is published from the SAME exact cell list collision
+ // just used (`exactCells`) — not a second, independently-flooded
+ // array — mirroring retail's SetPositionInternal
+ // add_shadows_to_cells(this, &arg2->cell_array) call (pc:283537),
+ // which consumes the transition's own array for both products at
+ // once.
+ PublishRetailProductFromExactCells(entityId, exactCells);
BumpOwnerVersion(entityId);
}
@@ -2470,9 +2688,30 @@ public sealed class ShadowObjectRegistry
BumpOwnerVersion(entityId);
}
- /// Remove an entity from all cells it was registered in.
+ ///
+ /// Remove an entity from all cells it was registered in. Campaign
+ /// OVERHAUL S2 chunk 4: mirrors retail's remove_shadows_from_cells
+ /// (0x00511230) full symmetry — a genuine teardown (as opposed to the
+ /// internal "clear then re-register" idiom /
+ /// use ahead of their own flood, which
+ /// must NOT detach children) cascades through every attached child
+ /// (Contract B: "remove_shadows_from_cells... recurses through
+ /// children"), and detaches this entity itself if it was ITSELF an
+ /// attached child.
+ ///
public void Deregister(uint entityId)
- => DeregisterCore(entityId, publishMutation: true);
+ {
+ if (_parentChildren.TryGetValue(entityId, out List? children)
+ && children.Count > 0)
+ {
+ uint[] toDetach = children.ToArray();
+ for (int i = 0; i < toDetach.Length; i++)
+ DetachChildCore(toDetach[i], removeFromParentList: false);
+ _parentChildren.Remove(entityId);
+ }
+ DetachChildCore(entityId, removeFromParentList: true);
+ DeregisterCore(entityId, publishMutation: true);
+ }
private void DeregisterCore(uint entityId, bool publishMutation)
{
@@ -3147,8 +3386,8 @@ public sealed class ShadowObjectRegistry
}
// 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
+ // without this, a staging registry's own internal move-path publish
+ // (PublishRetailProductFromExactCells, 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.
@@ -3428,6 +3667,17 @@ public sealed class ShadowObjectRegistry
_prefixScratch.Clear();
_removedPrefixScratch.Clear();
_pendingSetPositionDispatches.Clear();
+ // The retail render product (chunk 1/3) and the attach-chain state
+ // (chunk 4) were missing from this reset — a stale entry would
+ // otherwise survive a terminal Clear() into whatever the SAME
+ // registry instance is used for next.
+ _entityRetailPartArrays.Clear();
+ _retailCellArrays.Clear();
+ _retailCellArrayRoutes.Clear();
+ _retailPartEntriesByCell.Clear();
+ _childParent.Clear();
+ _parentChildren.Clear();
+ _childPartArrays.Clear();
_fallback = null;
}
diff --git a/tests/AcDream.App.Tests/Physics/LiveEntityNetworkOnPositionCollapseMatrixTests.cs b/tests/AcDream.App.Tests/Physics/LiveEntityNetworkOnPositionCollapseMatrixTests.cs
index 5afcc329..50aa86f4 100644
--- a/tests/AcDream.App.Tests/Physics/LiveEntityNetworkOnPositionCollapseMatrixTests.cs
+++ b/tests/AcDream.App.Tests/Physics/LiveEntityNetworkOnPositionCollapseMatrixTests.cs
@@ -1388,7 +1388,9 @@ public sealed class LiveEntityNetworkOnPositionCollapseMatrixTests
static (_, _, _) =>
new ExactProjectionWithdrawalOutcome(
ExactProjectionWithdrawalDisposition.Superseded,
- null)),
+ null),
+ Shadows,
+ new PhysicsDataCache()),
new ProjectileController(Runtime),
animatedEntities,
new RemoteMovementObservationTracker(),
diff --git a/tests/AcDream.App.Tests/Physics/LiveEntityNetworkRemoteTeleportPresentationTests.cs b/tests/AcDream.App.Tests/Physics/LiveEntityNetworkRemoteTeleportPresentationTests.cs
index 3ff9ba57..1b114f6e 100644
--- a/tests/AcDream.App.Tests/Physics/LiveEntityNetworkRemoteTeleportPresentationTests.cs
+++ b/tests/AcDream.App.Tests/Physics/LiveEntityNetworkRemoteTeleportPresentationTests.cs
@@ -653,7 +653,9 @@ public sealed class LiveEntityNetworkRemoteTeleportPresentationTests
static (_, _, _) =>
new ExactProjectionWithdrawalOutcome(
ExactProjectionWithdrawalDisposition.Superseded,
- null)),
+ null),
+ Shadows,
+ new PhysicsDataCache()),
new ProjectileController(Runtime),
animatedEntities,
new RemoteMovementObservationTracker(),
diff --git a/tests/AcDream.App.Tests/Rendering/EquippedChildProjectionWithdrawalTests.cs b/tests/AcDream.App.Tests/Rendering/EquippedChildProjectionWithdrawalTests.cs
index 451f1b92..a1ec1403 100644
--- a/tests/AcDream.App.Tests/Rendering/EquippedChildProjectionWithdrawalTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/EquippedChildProjectionWithdrawalTests.cs
@@ -9,6 +9,7 @@ using AcDream.Content;
using AcDream.Core.Items;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
+using AcDream.Core.Physics;
using AcDream.Core.World;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
@@ -1767,7 +1768,9 @@ public sealed class EquippedChildProjectionWithdrawalTests
Live,
Poses,
update => Live.TryApplyParent(update, out _),
- withdraw);
+ withdraw,
+ new ShadowObjectRegistry(),
+ new PhysicsDataCache());
_lifecycle.Bind(new DelegateLiveEntityRuntimeComponentLifecycle(
Controller.OnLogicalTeardown));
}
diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkProductionWorldDataTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkProductionWorldDataTests.cs
index c06aa61e..f155d92a 100644
--- a/tests/AcDream.App.Tests/Rendering/Walk/WalkProductionWorldDataTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkProductionWorldDataTests.cs
@@ -231,16 +231,56 @@ public sealed class WalkProductionWorldDataTests
Assert.Empty(outdoor);
}
+ // Campaign OVERHAUL S2 chunk 4: ResolveDynamicRenderCells is now a direct
+ // TryGetRetailCellArray read for EVERY dynamic record, children included
+ // — ShadowObjectRegistry.AttachChild owns Contract B's child-inheritance
+ // recursion at the registry, so the render-side parent-chain walk this
+ // test used to exercise no longer exists.
+
+ private static ShadowShape ChildBsp(uint gfxObjId) =>
+ ShadowShape.Bsp(
+ gfxObjId,
+ Vector3.Zero,
+ Quaternion.Identity,
+ scale: 1f,
+ localGeometry: ShadowPartGeometry.Create(
+ new FlatCollisionSphere(Vector3.Zero, 1f), null));
+
[Fact]
- public void ResolveDynamicRenderCells_EquippedDescendantInheritsRootCellArray()
+ public void ResolveDynamicRenderCells_AttachedChildReadsTheRegistrysRetailCellArrayDirectly()
{
+ var shadows = new ShadowObjectRegistry();
+ const uint rootId = 0x5000000Au;
+ const uint childId = 0x800045EEu;
+ IReadOnlyList rootParts = new[] { ChildBsp(0x02000001u) };
+
+ shadows.RegisterMultiPart(
+ rootId,
+ new Vector3(12f, 12f, 50f),
+ Quaternion.Identity,
+ rootParts,
+ state: 0u,
+ flags: EntityCollisionFlags.None,
+ worldOffsetX: 0f,
+ worldOffsetY: 0f,
+ landblockId: 0xF4180000u,
+ seedCellId: 0xF4180104u,
+ isStatic: false,
+ partArray: rootParts);
+ Assert.True(shadows.TryGetRetailCellArray(rootId, out IReadOnlyList rootCells));
+ Assert.NotEmpty(rootCells); // the fixture must actually exercise a flood
+
+ // No render-side parent walk any more: Contract B's inheritance is
+ // owned by the registry at attach time.
+ Assert.True(shadows.AttachChild(childId, rootId, new[] { ChildBsp(0x02000002u) }));
+
RenderProjectionRecord wand = Record(
- id: 0x800045EEu,
+ id: childId,
position: Vector3.Zero) with
{
Source = new RenderSourceMetadata() with
{
- LocalEntityId = 0x800045EEu,
+ LocalEntityId = childId,
ParentCellId = 0xF4180104u,
},
EntityPayload = new RenderEntityPayload() with
@@ -248,27 +288,66 @@ public sealed class WalkProductionWorldDataTests
CasterIdentity = RenderCasterIdentityKind.EquippedChild,
},
};
- var owners = new Dictionary>
- {
- [0x5000000Au] = [0xF4180106u, 0xF4180104u],
- };
- var parents = new Dictionary
- {
- [0x800045EEu] = 0x80000461u,
- [0x80000461u] = 0x5000000Au,
- };
IReadOnlyList cells =
WalkProductionWorldData.ResolveDynamicRenderCells(
in wand,
- id => owners.TryGetValue(id, out IReadOnlyList? value)
- ? value
- : Array.Empty(),
- id => parents.TryGetValue(id, out uint value)
- ? value
- : null);
+ id => shadows.TryGetRetailCellArray(id, out IReadOnlyList c)
+ ? (true, c)
+ : (false, Array.Empty()),
+ shadows.GetOwnerCells,
+ out bool usedFallback);
- Assert.Equal([0xF4180106u, 0xF4180104u], cells);
+ Assert.False(usedFallback);
+ Assert.Equal(rootCells, cells);
+ }
+
+ [Fact]
+ public void ResolveDynamicRenderCells_UnregisteredEntityFallsBackToOwnerCells()
+ {
+ var shadows = new ShadowObjectRegistry();
+ const uint entityId = 0x00099Fu;
+ IReadOnlyList shapes = new[] { ChildBsp(0x02000003u) };
+ shadows.RegisterMultiPart(
+ entityId,
+ new Vector3(12f, 12f, 50f),
+ Quaternion.Identity,
+ shapes,
+ state: 0u,
+ flags: EntityCollisionFlags.None,
+ worldOffsetX: 0f,
+ worldOffsetY: 0f,
+ landblockId: 0xF4180000u,
+ seedCellId: 0xF4180104u,
+ isStatic: false);
+ // No partArray supplied above -> no retail cell array registered for
+ // this entity (item E of the S2 chunk-1 contract), so the ordinary
+ // collision-flood answer is the conservative fallback.
+ Assert.False(shadows.TryGetRetailCellArray(entityId, out _));
+ Assert.NotEmpty(shadows.GetOwnerCells(entityId));
+
+ RenderProjectionRecord record = Record(
+ id: entityId,
+ position: Vector3.Zero) with
+ {
+ Source = new RenderSourceMetadata() with
+ {
+ LocalEntityId = entityId,
+ ParentCellId = 0xF4180104u,
+ },
+ };
+
+ IReadOnlyList cells =
+ WalkProductionWorldData.ResolveDynamicRenderCells(
+ in record,
+ id => shadows.TryGetRetailCellArray(id, out IReadOnlyList c)
+ ? (true, c)
+ : (false, Array.Empty()),
+ shadows.GetOwnerCells,
+ out bool usedFallback);
+
+ Assert.True(usedFallback);
+ Assert.Equal(shadows.GetOwnerCells(entityId), cells);
}
// -----------------------------------------------------------------
diff --git a/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryRetailCellArrayTests.cs b/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryRetailCellArrayTests.cs
index c2858c95..5a8640e3 100644
--- a/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryRetailCellArrayTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryRetailCellArrayTests.cs
@@ -294,8 +294,17 @@ public class ShadowObjectRegistryRetailCellArrayTests
e => e.EntityId == entityId);
}
+ // -------------------------------------------------------------------
+ // Campaign OVERHAUL S2 chunk 4: "one array, two products" on the move
+ // path too. Retail's SetPositionInternal (pc:283399) republishes BOTH
+ // the collision CShadowObj list and the retail render product from the
+ // SAME transition cell_array (pc:283536-283537) — never a second,
+ // independently-flooded array. The retail array must therefore equal
+ // GetOwnerCells after every CommitSetPosition variant.
+ // -------------------------------------------------------------------
+
[Fact]
- public void CommitSetPosition_WithRetainedPartArray_RecomputesRetailCellArray()
+ public void CommitSetPosition_NoneAction_PublishesRetailProductFromTheRetainedCells()
{
var reg = new ShadowObjectRegistry();
const uint entityId = 0x16u;
@@ -307,7 +316,13 @@ public class ShadowObjectRegistryRetailCellArrayTests
Assert.True(reg.TryGetRetailCellArray(entityId, out var before));
Assert.Equal(new[] { CellA }, before);
- var moved = new Vector3(42f, 12f, 50f); // -> CellB
+ // A .None commit carries no transition cell_array of its own (it
+ // maps to retail's "cell did not change" branch, e.g. ForceIntoCell
+ // with changedCell == false) — CommitSetPosition's None action
+ // republishes at the RETAINED cells (CellA), not a fresh flood from
+ // the new world position, even though (42,12,50) would geometrically
+ // resolve to CellB. Both products must agree either way.
+ var moved = new Vector3(42f, 12f, 50f);
reg.CommitSetPosition(
entityId,
moved,
@@ -318,8 +333,72 @@ public class ShadowObjectRegistryRetailCellArrayTests
action: PhysicsShadowCommitAction.None,
crossCellIds: ImmutableArray.Empty);
+ Assert.Equal(new[] { CellA }, reg.GetOwnerCells(entityId));
Assert.True(reg.TryGetRetailCellArray(entityId, out var after));
- Assert.Equal(new[] { CellB }, after);
+ Assert.Equal(reg.GetOwnerCells(entityId), after);
+ }
+
+ [Fact]
+ public void CommitSetPosition_ReplaceAction_PublishesRetailProductFromTheTransitionCellArray()
+ {
+ var reg = new ShadowObjectRegistry();
+ const uint entityId = 0x18u;
+ IReadOnlyList parts = new[] { Cyl(0x6101u) };
+
+ reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, parts,
+ state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
+ isStatic: false, partArray: parts);
+ Assert.True(reg.TryGetRetailCellArray(entityId, out var before));
+ Assert.Equal(new[] { CellA }, before);
+
+ // .Replace supplies the transition's own crossCellIds — retail's
+ // arg2->cell_array. Both products move to it directly; there is no
+ // second, independently-computed cell array to disagree with it.
+ var moved = new Vector3(42f, 12f, 50f);
+ reg.CommitSetPosition(
+ entityId,
+ moved,
+ Quaternion.Identity,
+ seedCellId: CellB,
+ worldOffsetX: OffX,
+ worldOffsetY: OffY,
+ action: PhysicsShadowCommitAction.Replace,
+ crossCellIds: [CellB]);
+
+ Assert.Equal(new[] { CellB }, reg.GetOwnerCells(entityId));
+ Assert.True(reg.TryGetRetailCellArray(entityId, out var after));
+ Assert.Equal(reg.GetOwnerCells(entityId), after);
+ }
+
+ [Fact]
+ public void CommitSetPosition_KeepWhenEmpty_LeavesBothProductsUntouched()
+ {
+ var reg = new ShadowObjectRegistry();
+ const uint entityId = 0x19u;
+ IReadOnlyList parts = new[] { Cyl(0x6201u) };
+
+ reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, parts,
+ state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
+ isStatic: false, partArray: parts);
+ Assert.True(reg.TryGetRetailCellArray(entityId, out var before));
+ Assert.Equal(new[] { CellA }, before);
+
+ // Retail's num_cells > 0 gate (pc:283534/pc:283540): an EMPTY
+ // transition cell_array leaves the previous shadows exactly as they
+ // were for BOTH products — no republish of either.
+ reg.CommitSetPosition(
+ entityId,
+ Pos,
+ Quaternion.Identity,
+ seedCellId: CellA,
+ worldOffsetX: OffX,
+ worldOffsetY: OffY,
+ action: PhysicsShadowCommitAction.Preserve,
+ crossCellIds: ImmutableArray.Empty);
+
+ Assert.Equal(new[] { CellA }, reg.GetOwnerCells(entityId));
+ Assert.True(reg.TryGetRetailCellArray(entityId, out var after));
+ Assert.Equal(new[] { CellA }, after);
}
// -------------------------------------------------------------------
@@ -564,4 +643,228 @@ public class ShadowObjectRegistryRetailCellArrayTests
Assert.Single(partEntries);
Assert.Equal(0x8101u, partEntries[0].GfxObjId);
}
+
+ // -------------------------------------------------------------------
+ // Campaign OVERHAUL S2 chunk 4: AttachChild/DetachChild — retail's
+ // add_shadows_to_cells child-inheritance recursion (Contract B). An
+ // attached projection (equipped weapon/shield/ammunition) owns no
+ // independent collision shapes; it inherits the root's CELLARRAY and
+ // gets PART ENTRIES only, never a collision row.
+ // -------------------------------------------------------------------
+
+ private static IReadOnlyList ChildParts(params uint[] gfxObjIds)
+ {
+ var shapes = new ShadowShape[gfxObjIds.Length];
+ for (int i = 0; i < gfxObjIds.Length; i++)
+ shapes[i] = Bsp(gfxObjIds[i]);
+ return shapes;
+ }
+
+ [Fact]
+ public void AttachChild_GivesChildTheRootsArrayAndEntries()
+ {
+ var reg = new ShadowObjectRegistry();
+ const uint rootId = 0x40u;
+ const uint childId = 0x41u;
+ IReadOnlyList rootParts = new[] { Cyl(0x9001u) };
+
+ reg.RegisterMultiPart(rootId, Pos, Quaternion.Identity, rootParts,
+ state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
+ partArray: rootParts);
+ Assert.True(reg.TryGetRetailCellArray(rootId, out var rootCells));
+ Assert.Equal(new[] { CellA }, rootCells);
+
+ IReadOnlyList childParts = ChildParts(0xC001u, 0xC002u);
+ Assert.True(reg.AttachChild(childId, rootId, childParts));
+
+ Assert.True(reg.TryGetRetailCellArray(childId, out var childCells));
+ Assert.Equal(rootCells, childCells);
+
+ var entries = reg.GetRetailPartEntriesInCell(CellA)
+ .Where(e => e.EntityId == childId)
+ .OrderBy(e => e.PartIndex)
+ .ToList();
+ Assert.Equal(2, entries.Count);
+ Assert.Equal(0xC001u, entries[0].GfxObjId);
+ Assert.Equal(0xC002u, entries[1].GfxObjId);
+
+ // Never a collision row — acdream's attached projections own none.
+ Assert.Empty(reg.GetOwnerCells(childId));
+ Assert.DoesNotContain(
+ reg.GetObjectsInCell(CellA),
+ e => e.EntityId == childId);
+ }
+
+ [Fact]
+ public void AttachChild_RootMove_RepublishesTheChild()
+ {
+ var reg = new ShadowObjectRegistry();
+ const uint rootId = 0x42u;
+ const uint childId = 0x43u;
+ IReadOnlyList rootParts = new[] { Cyl(0x9101u) };
+
+ reg.RegisterMultiPart(rootId, Pos, Quaternion.Identity, rootParts,
+ state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
+ isStatic: false, partArray: rootParts);
+ Assert.True(reg.AttachChild(childId, rootId, ChildParts(0xC101u)));
+ Assert.True(reg.TryGetRetailCellArray(childId, out var before));
+ Assert.Equal(new[] { CellA }, before);
+
+ var moved = new Vector3(42f, 12f, 50f); // -> CellB
+ reg.CommitSetPosition(
+ rootId,
+ moved,
+ Quaternion.Identity,
+ seedCellId: CellB,
+ worldOffsetX: OffX,
+ worldOffsetY: OffY,
+ action: PhysicsShadowCommitAction.Replace,
+ crossCellIds: [CellB]);
+
+ Assert.True(reg.TryGetRetailCellArray(rootId, out var rootAfter));
+ Assert.Equal(new[] { CellB }, rootAfter);
+ Assert.True(reg.TryGetRetailCellArray(childId, out var childAfter));
+ Assert.Equal(rootAfter, childAfter);
+
+ Assert.DoesNotContain(
+ reg.GetRetailPartEntriesInCell(CellA),
+ e => e.EntityId == childId);
+ var movedEntries = reg.GetRetailPartEntriesInCell(CellB)
+ .Where(e => e.EntityId == childId).ToList();
+ Assert.Single(movedEntries);
+ Assert.Equal(0xC101u, movedEntries[0].GfxObjId);
+ }
+
+ [Fact]
+ public void DetachChild_ClearsTheChildsProducts()
+ {
+ var reg = new ShadowObjectRegistry();
+ const uint rootId = 0x44u;
+ const uint childId = 0x45u;
+ IReadOnlyList rootParts = new[] { Cyl(0x9201u) };
+
+ reg.RegisterMultiPart(rootId, Pos, Quaternion.Identity, rootParts,
+ state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
+ partArray: rootParts);
+ Assert.True(reg.AttachChild(childId, rootId, ChildParts(0xC201u)));
+ Assert.True(reg.TryGetRetailCellArray(childId, out _));
+
+ Assert.True(reg.DetachChild(childId));
+
+ Assert.False(reg.TryGetRetailCellArray(childId, out var afterCells));
+ Assert.Empty(afterCells);
+ Assert.DoesNotContain(
+ reg.GetRetailPartEntriesInCell(CellA),
+ e => e.EntityId == childId);
+ // The root itself is untouched.
+ Assert.True(reg.TryGetRetailCellArray(rootId, out var rootCells));
+ Assert.Equal(new[] { CellA }, rootCells);
+
+ // A second detach is a no-op, not an error.
+ Assert.False(reg.DetachChild(childId));
+ }
+
+ [Fact]
+ public void Deregister_RootCascadesDetachOfAttachedChildren()
+ {
+ var reg = new ShadowObjectRegistry();
+ const uint rootId = 0x46u;
+ const uint childId = 0x47u;
+ IReadOnlyList rootParts = new[] { Cyl(0x9301u) };
+
+ reg.RegisterMultiPart(rootId, Pos, Quaternion.Identity, rootParts,
+ state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
+ partArray: rootParts);
+ Assert.True(reg.AttachChild(childId, rootId, ChildParts(0xC301u)));
+
+ reg.Deregister(rootId);
+
+ Assert.False(reg.TryGetRetailCellArray(rootId, out _));
+ Assert.False(reg.TryGetRetailCellArray(childId, out var childCells));
+ Assert.Empty(childCells);
+ Assert.DoesNotContain(
+ reg.GetRetailPartEntriesInCell(CellA),
+ e => e.EntityId == childId);
+ }
+
+ [Fact]
+ public void AttachChild_NestedChildOfAChild_ResolvesToTheRoot()
+ {
+ var reg = new ShadowObjectRegistry();
+ const uint rootId = 0x48u;
+ const uint directChildId = 0x49u;
+ const uint grandchildId = 0x4Au;
+ IReadOnlyList rootParts = new[] { Cyl(0x9401u) };
+
+ reg.RegisterMultiPart(rootId, Pos, Quaternion.Identity, rootParts,
+ state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
+ partArray: rootParts);
+ Assert.True(reg.AttachChild(directChildId, rootId, ChildParts(0xC401u)));
+ // Attach the grandchild to the DIRECT CHILD, not the root — nested
+ // attachment must still resolve to the ultimate root's array.
+ Assert.True(reg.AttachChild(grandchildId, directChildId, ChildParts(0xC402u)));
+
+ Assert.True(reg.TryGetRetailCellArray(rootId, out var rootCells));
+ Assert.True(reg.TryGetRetailCellArray(grandchildId, out var grandchildCells));
+ Assert.Equal(rootCells, grandchildCells);
+
+ var entries = reg.GetRetailPartEntriesInCell(CellA)
+ .Where(e => e.EntityId == grandchildId).ToList();
+ Assert.Single(entries);
+ Assert.Equal(0xC402u, entries[0].GfxObjId);
+
+ // Moving the root republishes BOTH the direct child and the
+ // grandchild, in child-list order.
+ var moved = new Vector3(42f, 12f, 50f);
+ reg.CommitSetPosition(
+ rootId, moved, Quaternion.Identity, seedCellId: CellB,
+ worldOffsetX: OffX, worldOffsetY: OffY,
+ action: PhysicsShadowCommitAction.Replace, crossCellIds: [CellB]);
+
+ Assert.True(reg.TryGetRetailCellArray(grandchildId, out var grandchildAfter));
+ Assert.Equal(new[] { CellB }, grandchildAfter);
+
+ // Detaching the direct child cascades to the grandchild too.
+ Assert.True(reg.DetachChild(directChildId));
+ Assert.False(reg.TryGetRetailCellArray(grandchildId, out _));
+ }
+
+ [Fact]
+ public void AttachChild_SelfAttachRejected()
+ {
+ var reg = new ShadowObjectRegistry();
+ const uint entityId = 0x4Bu;
+ IReadOnlyList rootParts = new[] { Cyl(0x9501u) };
+
+ reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, rootParts,
+ state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
+ partArray: rootParts);
+
+ Assert.False(reg.AttachChild(entityId, entityId, ChildParts(0xC501u)));
+ }
+
+ [Fact]
+ public void AttachChild_CycleRejected_RootUnaffected()
+ {
+ var reg = new ShadowObjectRegistry();
+ const uint rootId = 0x4Cu;
+ const uint childId = 0x4Du;
+ IReadOnlyList rootParts = new[] { Cyl(0x9601u) };
+
+ reg.RegisterMultiPart(rootId, Pos, Quaternion.Identity, rootParts,
+ state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
+ partArray: rootParts);
+ Assert.True(reg.AttachChild(childId, rootId, ChildParts(0xC601u)));
+ Assert.True(reg.TryGetRetailCellArray(rootId, out var rootCellsBefore));
+
+ // Attaching the root TO its own child would form a cycle.
+ Assert.False(reg.AttachChild(rootId, childId, ChildParts(0xC602u)));
+
+ Assert.True(reg.TryGetRetailCellArray(rootId, out var rootCellsAfter));
+ Assert.Equal(rootCellsBefore, rootCellsAfter);
+ Assert.Equal(new[] { CellA }, reg.GetOwnerCells(rootId));
+ // The child's own attachment is unaffected by the rejected call.
+ Assert.True(reg.TryGetRetailCellArray(childId, out var childCells));
+ Assert.Equal(rootCellsAfter, childCells);
+ }
}