feat(physics): S2 chunk 4 - movement publishes from the transition's cells; children inherit at registration

Movement: CommitSetPosition's RefreshPositionRows/ReplacePositionRows and
the staged apply publish the retail render product from the exact cell
list collision just used, the transition's cell_array retail feeds
add_shadows_to_cells in CPhysicsObj::SetPositionInternal @0x00515330
(pseudo-C 283526-283539); the separate move-path bbox recompute is deleted.
calc_cross_cells @0x00515230 stays the distinct full-recompute path
(PhysicsShadowCommitAction.Recalculate).

Children (Contract B recursion): ShadowObjectRegistry.AttachChild/DetachChild
give an attached object the root's current cells as part entries only,
republished whenever the root's array changes, detached at withdrawal and
cascaded from the root's Deregister; nested attachment resolves to the root
with a bounded, cycle-safe chain. EquippedChildRenderController attaches at
realization (FromSetupRenderParts over the child's Setup) and detaches at
its single removal funnel. WalkProductionWorldData's dynamic sweep reads
TryGetRetailCellArray directly; the 64-hop parent-chain walk and its
FindParentLocalId plumbing are deleted. CollisionWorldState.Clear now
also clears the retail products.

Gates (implementer's isolated worktree at identical content): Release
build 0/0; Core 4,970/4,970; App hermetic 6,761/6,761; targeted
walk/child/live-entity/placement/comparator 166/166; Runtime 1,884/1,884.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-09-02 23:37:34 +02:00
parent 706fc49397
commit 75ea269d35
14 changed files with 1072 additions and 179 deletions

View file

@ -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 regression: its authored parent is `0x8A02015F`, while its visual parts also
register in `0x8A02015E` and the adjoining vertical cells. 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 Moving multipart objects follow the sibling retail route rather than their
authored/feet cell alone: `CPhysicsObj::add_shadows_to_cells` installs every authored/feet cell alone: `CPhysicsObj::add_shadows_to_cells` installs every
`CPartArray` part in every cell of the object's retained `CELLARRAY`, and `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 `CPhysicsPart::Draw` stamps each part only after that part passes a cell's
portal test. `WalkProductionWorldData` therefore buckets live projections by portal test. `WalkProductionWorldData.ResolveDynamicRenderCells` therefore
all `ShadowObjectRegistry.GetOwnerCells` rows, while the walk classifier owns reads `ShadowObjectRegistry.TryGetRetailCellArray` directly for every dynamic
a projection+part drawn-pass stamp. Retail advances that stamp after 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 `LScape::draw` + `FlushAlphaList` and before the interior depth clear
(`PView::DrawCells` @0x005A4886), so a part may draw once in the landscape (`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 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 classifier at that exact walk boundary. Never replace the per-part stamp with
passes the root's same `CELLARRAY` to every attached child, so equipped-child render a whole-projection drawn-once gate: at a stair portal the torso may pass in
projections resolve their accepted parent chain to the root's owner cells; the first cell while head/hair fails and must retry through another crossed
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
cell. One binding exception is the local player: retail cell. One binding exception is the local player: retail
`RenderDeviceD3D::DrawMeshInternal` bypasses `Get/SetDrawnThisFrame` when `RenderDeviceD3D::DrawMeshInternal` bypasses `Get/SetDrawnThisFrame` when
`CPhysicsPart::IsPartOfPlayerObj` is true, repainting the player's parts at `CPhysicsPart::IsPartOfPlayerObj` is true, repainting the player's parts at
every crossed-cell turn so later wall/depth ordering remains correct. 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, **Slice I3 prepared collision extension (2026-07-25).** At its introduction,
the package remained format 1 and retained mesh type values 13; bake-tool 4 the package remained format 1 and retained mesh type values 13; bake-tool 4
appended typed GfxObj, appended typed GfxObj,

View file

@ -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) | | **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` | | **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 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 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 | | **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 |
| `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).** `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) | | `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) | | `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` | | 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. registration that carries a real part array.
- Render membership is whole-object per cell; retail's is every part per - Render membership is whole-object per cell; retail's is every part per
cell (identity retained per part, with cell frame and clip planes). cell (identity retained per part, with cell frame and clip planes).
- Children are inherited only on the render side, at bucket time; retail - **RESOLVED (chunk 4).** Children were inherited only on the render side, at
inherits at registration. 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 - `b3b7d922` removed the indoor-seed stab-list prune inside the shared
`BuildShadowCellSetFromParts`, affecting every caller. Retail has no such `BuildShadowCellSetFromParts`, affecting every caller. Retail has no such
prune in the bbox route (Contract A), so the removal is the retail 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 connected nine-stop route; `CellTransit`'s prune removal is confirmed
against Contract A. against Contract A.
**Chunk 4 — dynamics and children.** `add_shadows_to_cells` recursion: **Chunk 4 (landed 2026-09-02) — dynamics and children.** `add_shadows_to_cells`
children register with the root's CELLARRAY at registration and movement; recursion: children register with the root's CELLARRAY at registration and
particles own-cell only; the render-side parent-chain walk is deleted. 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` **Chunk 5 — consumer cutover and deletion.** Remaining `WalkProductionWorldData`
buckets become borrowed views; particle owner-cell union reads the same; 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) `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 territory, to be implemented if/when particle emitters are ever routed
through this registry. 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.

View file

@ -507,8 +507,7 @@ internal sealed class FrameRootCompositionPhase
?? throw new InvalidOperationException( ?? throw new InvalidOperationException(
"The retail frame walk requires the landscape registry."), "The retail frame walk requires the landscape registry."),
d.CellVisibility, d.CellVisibility,
d.PhysicsEngine.ShadowObjects, d.PhysicsEngine.ShadowObjects),
live.EquippedChildren.FindParentLocalId),
retailPViewPassExecutor, retailPViewPassExecutor,
retailPViewPassExecutor), retailPViewPassExecutor),
retailPViewCells, retailPViewCells,

View file

@ -622,7 +622,9 @@ internal sealed class LivePresentationCompositionPhase
childRecord, childRecord,
positionVersion, positionVersion,
projectionVersion, projectionVersion,
d.PlayerIdentity.ServerGuid)), d.PlayerIdentity.ServerGuid),
d.PhysicsEngine.ShadowObjects,
d.PhysicsDataCache),
static value => value.Dispose()); static value => value.Dispose());
Fault(LivePresentationCompositionPoint.CorePresentationCreated); Fault(LivePresentationCompositionPoint.CorePresentationCreated);

View file

@ -34,6 +34,28 @@ public sealed class EquippedChildRenderController : IDisposable
_withdrawProjection; _withdrawProjection;
private readonly EntityEffectPoseRegistry _poses; private readonly EntityEffectPoseRegistry _poses;
/// <summary>
/// Campaign OVERHAUL S2 chunk 4: the SAME canonical shadow registry
/// <c>LiveEntityCollisionBuilder</c> publishes into — retail's
/// <c>add_shadows_to_cells</c> 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
/// <see cref="ShadowObjectRegistry.AttachChild"/>/<see cref="ShadowObjectRegistry.DetachChild"/>
/// — never Register/RegisterMultiPart.
/// </summary>
private readonly ShadowObjectRegistry _shadows;
/// <summary>
/// Campaign OVERHAUL S2 chunk 4: the SAME <c>PhysicsDataCache</c> resolver
/// pair <see cref="AcDream.Runtime.Physics.LiveEntityCollisionBuilder"/>
/// uses for <c>ShadowShapeBuilder.FromSetupRenderParts</c> — a static and
/// a live Setup sharing a GfxObj id can never disagree about its render
/// geometry.
/// </summary>
private readonly PhysicsDataCache _physicsData;
private ParentAttachmentState Relations => _liveEntities.ParentAttachments; private ParentAttachmentState Relations => _liveEntities.ParentAttachments;
/// <summary>Raised after the attached projection is fully registered.</summary> /// <summary>Raised after the attached projection is fully registered.</summary>
@ -98,7 +120,9 @@ public sealed class EquippedChildRenderController : IDisposable
EntityEffectPoseRegistry poses, EntityEffectPoseRegistry poses,
Func<ParentEvent.Parsed, bool> acceptParent, Func<ParentEvent.Parsed, bool> acceptParent,
Func<LiveEntityRecord, ulong, ulong, ExactProjectionWithdrawalOutcome> Func<LiveEntityRecord, ulong, ulong, ExactProjectionWithdrawalOutcome>
withdrawProjection) withdrawProjection,
ShadowObjectRegistry shadows,
PhysicsDataCache physicsData)
{ {
_dats = dats ?? throw new ArgumentNullException(nameof(dats)); _dats = dats ?? throw new ArgumentNullException(nameof(dats));
_datLock = datLock ?? throw new ArgumentNullException(nameof(datLock)); _datLock = datLock ?? throw new ArgumentNullException(nameof(datLock));
@ -108,6 +132,8 @@ public sealed class EquippedChildRenderController : IDisposable
_acceptParent = acceptParent ?? throw new ArgumentNullException(nameof(acceptParent)); _acceptParent = acceptParent ?? throw new ArgumentNullException(nameof(acceptParent));
_withdrawProjection = withdrawProjection _withdrawProjection = withdrawProjection
?? throw new ArgumentNullException(nameof(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; _parentOfAttached = static child => child.ParentRecord.ProjectionKey;
_tickAttached = TickChild; _tickAttached = TickChild;
_reconcileAttached = ReconcileChild; _reconcileAttached = ReconcileChild;
@ -659,6 +685,15 @@ public sealed class EquippedChildRenderController : IDisposable
CaptureParentPresentation(attached, parentEntity); CaptureParentPresentation(attached, parentEntity);
RuntimeEntityKey childKey = RequireProjectionKey(childRecord); RuntimeEntityKey childKey = RequireProjectionKey(childRecord);
_attachedByChild[childKey] = attached; _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); _pendingUnparentByChild.Remove(childKey);
if ((parentRecord.FinalPhysicsState & PhysicsStateFlags.Hidden) != 0) if ((parentRecord.FinalPhysicsState & PhysicsStateFlags.Hidden) != 0)
{ {
@ -1147,6 +1182,38 @@ public sealed class EquippedChildRenderController : IDisposable
return available; return available;
} }
/// <summary>
/// Campaign OVERHAUL S2 chunk 4: retail's per-part render membership
/// (<c>CPartArray::AddPartsShadow</c> 0x00517e40 walks EVERY visual
/// part, not just the BSP-only collision dispatch) for one attached
/// child, via <see cref="ShadowShapeBuilder.FromSetupRenderParts"/> — the
/// SAME builder and the SAME <see cref="_physicsData"/> resolver pair
/// <c>LiveEntityCollisionBuilder.Build</c> 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.
/// <paramref name="template"/>'s per-part GfxObj identity (post-
/// AnimPartChanged, <see cref="BuildPartTemplate"/>) supplies
/// <c>effectivePartGfxObjIds</c>; 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 <c>FromSetupRenderParts</c>'s own placement-frame fallback.
/// </summary>
private IReadOnlyList<ShadowShape> BuildChildRenderParts(
Setup setup, IReadOnlyList<MeshRef> 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) private static PaletteOverride? BuildPaletteOverride(WorldSession.EntitySpawn spawn)
{ {
if (spawn.SubPalettes is not { Count: > 0 } subPalettes) if (spawn.SubPalettes is not { Count: > 0 } subPalettes)
@ -1388,6 +1455,12 @@ public sealed class EquippedChildRenderController : IDisposable
} }
_attachedByChild.Remove(key); _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); ProjectionRemoved?.Invoke(child.ChildRecord);
return true; return true;
} }

View file

@ -78,8 +78,7 @@ internal sealed class RetailPViewRenderer
Walk.WalkBuildingRegistry walkBuildings, Walk.WalkBuildingRegistry walkBuildings,
Walk.WalkLandscapeAssembler walkLandscape, Walk.WalkLandscapeAssembler walkLandscape,
CellVisibility walkCellRegistry, CellVisibility walkCellRegistry,
ShadowObjectRegistry shadows, ShadowObjectRegistry shadows)
Func<uint, uint?>? findParentLocalId = null)
{ {
_renderSceneShadow = renderSceneShadow _renderSceneShadow = renderSceneShadow
?? throw new ArgumentNullException(nameof(renderSceneShadow)); ?? throw new ArgumentNullException(nameof(renderSceneShadow));
@ -91,8 +90,7 @@ internal sealed class RetailPViewRenderer
?? throw new ArgumentNullException(nameof(walkCellRegistry)); ?? throw new ArgumentNullException(nameof(walkCellRegistry));
_walkWorldData = new Walk.WalkProductionWorldData( _walkWorldData = new Walk.WalkProductionWorldData(
_walkBuildings, _walkBuildings,
shadows ?? throw new ArgumentNullException(nameof(shadows)), shadows ?? throw new ArgumentNullException(nameof(shadows)));
findParentLocalId);
_walkClearInteriorDepthAction = ClearWalkInteriorDepth; _walkClearInteriorDepthAction = ClearWalkInteriorDepth;
_walkDrawExitSealsAction = DrawWalkExitSeals; _walkDrawExitSealsAction = DrawWalkExitSeals;
} }

View file

@ -78,7 +78,6 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
{ {
private readonly WalkBuildingRegistry _buildings; private readonly WalkBuildingRegistry _buildings;
private readonly ShadowObjectRegistry _shadows; private readonly ShadowObjectRegistry _shadows;
private readonly Func<uint, uint?> _findParentLocalId;
private RenderSceneQuery _scene; private RenderSceneQuery _scene;
private uint _tupleLandblockId; private uint _tupleLandblockId;
private int _renderCenterLbX; private int _renderCenterLbX;
@ -107,17 +106,13 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
internal WalkProductionWorldData( internal WalkProductionWorldData(
WalkBuildingRegistry buildings, WalkBuildingRegistry buildings,
ShadowObjectRegistry shadows, ShadowObjectRegistry shadows)
Func<uint, uint?>? findParentLocalId = null)
{ {
_buildings = buildings ?? throw new ArgumentNullException(nameof(buildings)); _buildings = buildings ?? throw new ArgumentNullException(nameof(buildings));
_shadows = shadows ?? throw new ArgumentNullException(nameof(shadows)); _shadows = shadows ?? throw new ArgumentNullException(nameof(shadows));
_findParentLocalId = findParentLocalId ?? NoParentLocalId;
_tryGetRetailCellArray = TryGetRetailCellArrayForEntity; _tryGetRetailCellArray = TryGetRetailCellArrayForEntity;
} }
private static uint? NoParentLocalId(uint _) => null;
private (bool Found, IReadOnlyList<uint> Cells) TryGetRetailCellArrayForEntity(uint entityId) private (bool Found, IReadOnlyList<uint> Cells) TryGetRetailCellArrayForEntity(uint entityId)
{ {
bool found = _shadows.TryGetRetailCellArray(entityId, out IReadOnlyList<uint> cells); bool found = _shadows.TryGetRetailCellArray(entityId, out IReadOnlyList<uint> cells);
@ -264,8 +259,11 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
ref readonly RenderProjectionRecord record = ref _dynamicSweepScratch[i]; ref readonly RenderProjectionRecord record = ref _dynamicSweepScratch[i];
IReadOnlyList<uint> renderCells = ResolveDynamicRenderCells( IReadOnlyList<uint> renderCells = ResolveDynamicRenderCells(
in record, in record,
_tryGetRetailCellArray,
_shadows.GetOwnerCells, _shadows.GetOwnerCells,
_findParentLocalId); out bool usedFallback);
if (usedFallback)
UnregisteredStaticRenderFallbackCount++;
BucketDynamicRecord( BucketDynamicRecord(
in record, in record,
renderCells, renderCells,
@ -277,48 +275,39 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
} }
/// <summary> /// <summary>
/// Retail <c>CPhysicsObj::add_shadows_to_cells</c> recursively passes the /// Campaign OVERHAUL S2 chunk 4: one dynamic record's render-cell
/// root object's same CELLARRAY to every child in its CHILDLIST. Attached /// membership, borrowed from <see cref="ShadowObjectRegistry.TryGetRetailCellArray"/>
/// projections deliberately own no independent collision rows in acdream, /// — the SAME direct read <see cref="ResolveOutdoorStaticRenderCells"/>
/// so walk their accepted parent chain until the root's retained array is /// uses. An equipped child's retail array is now published by
/// found. Ordinary dynamics continue to consume only their own rows. /// <see cref="ShadowObjectRegistry.AttachChild"/> at the registry (retail
/// Contract B's <c>add_shadows_to_cells</c> 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 <see cref="ResolveIndoorStaticRenderCells"/>
/// documents), the fallback is today's collision-flood
/// <see cref="ShadowObjectRegistry.GetOwnerCells"/> answer, counted the
/// same way as the static fallbacks via <paramref name="usedFallback"/>.
/// </summary> /// </summary>
internal static IReadOnlyList<uint> ResolveDynamicRenderCells( internal static IReadOnlyList<uint> ResolveDynamicRenderCells(
in RenderProjectionRecord record, in RenderProjectionRecord record,
Func<uint, (bool Found, IReadOnlyList<uint> Cells)> tryGetRetailCellArray,
Func<uint, IReadOnlyList<uint>> getOwnerCells, Func<uint, IReadOnlyList<uint>> getOwnerCells,
Func<uint, uint?> findParentLocalId) out bool usedFallback)
{ {
ArgumentNullException.ThrowIfNull(tryGetRetailCellArray);
ArgumentNullException.ThrowIfNull(getOwnerCells); ArgumentNullException.ThrowIfNull(getOwnerCells);
ArgumentNullException.ThrowIfNull(findParentLocalId);
uint current = record.Source.LocalEntityId; (bool found, IReadOnlyList<uint> cells) =
IReadOnlyList<uint> cells = getOwnerCells(current); tryGetRetailCellArray(record.Source.LocalEntityId);
if (cells.Count > 0 if (found)
|| record.EntityPayload.CasterIdentity
!= RenderCasterIdentityKind.EquippedChild)
{ {
usedFallback = false;
return cells; return cells;
} }
// ParentAttachmentState rejects cycles. Keep a hard bound here so a usedFallback = true;
// corrupted diagnostic callback still cannot stall a render frame. return getOwnerCells(record.Source.LocalEntityId);
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<uint>();
} }
/// <summary> /// <summary>

View file

@ -127,6 +127,17 @@ internal sealed class CollisionWorldState
ShadowEntityRetailCellArrayRoutes { get; } = new(); ShadowEntityRetailCellArrayRoutes { get; } = new();
internal Dictionary<uint, List<RetailPartEntry>> RetailPartEntriesByCell { get; } = new(); internal Dictionary<uint, List<RetailPartEntry>> 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<uint, uint> ShadowChildParent { get; } = new();
internal Dictionary<uint, List<uint>> ShadowParentChildren { get; } = new();
internal Dictionary<uint, IReadOnlyList<ShadowShape>>
ShadowChildPartArrays { get; } = new();
// ── O1 per-prefix installed-key ledgers ──────────────────────────────── // ── O1 per-prefix installed-key ledgers ────────────────────────────────
// Every mutation of the five landblock-scoped world maps goes through the // Every mutation of the five landblock-scoped world maps goes through the
// typed helpers below so these ledgers stay exact. The seal's landblock- // typed helpers below so these ledgers stay exact. The seal's landblock-

View file

@ -89,6 +89,27 @@ public sealed class ShadowObjectRegistry
private Dictionary<uint, List<RetailPartEntry>> _retailPartEntriesByCell => private Dictionary<uint, List<RetailPartEntry>> _retailPartEntriesByCell =>
_collisionWorld.Current.RetailPartEntriesByCell; _collisionWorld.Current.RetailPartEntriesByCell;
/// <summary>
/// Campaign OVERHAUL S2 chunk 4: immediate parent (attachment point) for
/// every entity attached via <see cref="AttachChild"/> — retail's
/// <c>add_shadows_to_cells</c> child-inheritance recursion (Contract B).
/// A nested attachment's ultimate root is resolved by walking this map;
/// see <see cref="ResolveAttachRoot"/>.
/// </summary>
private Dictionary<uint, uint> _childParent =>
_collisionWorld.Current.ShadowChildParent;
/// <summary>Direct children of one attachment point, in attach (child-list)
/// order — the reverse index of <see cref="_childParent"/>.</summary>
private Dictionary<uint, List<uint>> _parentChildren =>
_collisionWorld.Current.ShadowParentChildren;
/// <summary>Each attached child's OWN visual part array (its equipped
/// item's Setup parts), published into the root's cells by
/// <see cref="PublishChildEntries"/> — never flooded independently.</summary>
private Dictionary<uint, IReadOnlyList<ShadowShape>> _childPartArrays =>
_collisionWorld.Current.ShadowChildPartArrays;
/// <summary> /// <summary>
/// BR-7: per-entity registration arguments, kept so a registration can be /// BR-7: per-entity registration arguments, kept so a registration can be
/// RE-RUN when more cells hydrate. Retail's equivalent is /// RE-RUN when more cells hydrate. Retail's equivalent is
@ -400,61 +421,6 @@ public sealed class ShadowObjectRegistry
private PhysicsDataCache? _fallback; private PhysicsDataCache? _fallback;
private PhysicsDataCache FloodCache => DataCache ?? _fallbackCache; private PhysicsDataCache FloodCache => DataCache ?? _fallbackCache;
/// <summary>
/// Retail's exact <c>CObjCell::find_cell_list</c>/<c>find_bbox_cell_list</c>
/// CELLARRAY (Contract A, re-verified 2026-09-01 through the live Ghidra
/// bridge at <c>127.0.0.1:8081</c> against <c>patchmem.gpr</c>) for one
/// entity's WHOLE part array, plus the sibling
/// <c>CPartArray::AddPartsShadow</c> (0x00517e40) per-cell part-entry
/// product, published into <see cref="_retailPartEntriesByCell"/>.
///
/// <para>
/// 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 <see cref="_entityToCells"/> or
/// <see cref="_cells"/>, and nothing in production reads it yet.
/// </para>
///
/// <para>
/// Route — <c>CPhysicsObj::calc_cross_cells_static</c> 0x00515160's
/// branch table:
/// </para>
/// <list type="bullet">
/// <item>cylsphere (<c>CObjCell::find_cell_list</c> 0x0052b9f0, over the
/// authored CylSpheres, reusing <see cref="BuildFloodSpheres"/> — "today's
/// cylinder registration") when <c>(state &amp; 0x10000) == 0</c> AND the
/// object's authored COLLISION dispatch (<paramref name="collisionShapes"/>,
/// retail <c>CPartArray::GetNumCylsphere() != 0</c>: the Setup's
/// CylSpheres) carries at least one
/// <see cref="ShadowCollisionType.Cylinder"/> shape. The visual part array
/// never decides the route: retail's cylspheres are Setup collision data,
/// not parts;</item>
/// <item>otherwise the bbox route (<c>CPhysicsObj::find_bbox_cell_list</c>
/// 0x00510fc0 via <see cref="CellTransit.BuildShadowCellSetFromParts"/>) —
/// every other case, including a BSP-bearing object (state bit set) and a
/// part array with no authored CylSpheres at all.</item>
/// </list>
/// </summary>
private void RecomputeRetailCellArray(
uint entityId,
uint seedCellId,
Vector3 entityWorldPos,
Quaternion entityWorldRot,
uint state,
IReadOnlyList<ShadowShape> collisionShapes,
IReadOnlyList<ShadowShape> partArray,
bool isStatic)
{
ClearRetailCellArray(entityId);
if (partArray.Count == 0 || seedCellId == 0u)
return;
_entityRetailPartArrays[entityId] = partArray;
(IReadOnlyList<uint> cellArray, RetailCellArrayRoute route) = ComputeContractACellArray(
seedCellId, entityWorldPos, entityWorldRot, state, collisionShapes, partArray, isStatic);
PublishRetailCellArray(entityId, cellArray, route, partArray);
}
/// <summary> /// <summary>
/// Campaign OVERHAUL S2 chunk 3: retail's <c>calc_cross_cells_static</c> /// Campaign OVERHAUL S2 chunk 3: retail's <c>calc_cross_cells_static</c>
/// (0x00515160) dispatch + flood, PURE — no registry state is read or /// (0x00515160) dispatch + flood, PURE — no registry state is read or
@ -523,6 +489,16 @@ public sealed class ShadowObjectRegistry
/// <c>remove_shadows_from_cells</c> symmetry for this side product. The /// <c>remove_shadows_from_cells</c> symmetry for this side product. The
/// route is recorded even when <paramref name="cellArray"/> is empty — /// route is recorded even when <paramref name="cellArray"/> is empty —
/// the dispatch decision was still made. /// the dispatch decision was still made.
///
/// <para>
/// Campaign OVERHAUL S2 chunk 4: this is retail's ONE place a
/// <c>CPhysicsObj</c>'s CELLARRAY changes, so it is also the one place
/// that re-runs <see cref="RepublishAttachedChildren"/> — 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 (<see cref="TryPrepareSetPosition"/>), which never
/// carries any attach relationships of its own.
/// </para>
/// </summary> /// </summary>
private void PublishRetailCellArray( private void PublishRetailCellArray(
uint entityId, uint entityId,
@ -537,29 +513,43 @@ public sealed class ShadowObjectRegistry
} }
_retailCellArrayRoutes[entityId] = route; _retailCellArrayRoutes[entityId] = route;
if (cellArray.Count == 0) if (cellArray.Count == 0)
{
RepublishAttachedChildren(entityId);
return; return;
}
var orderedCells = new List<uint>(cellArray.Count); var orderedCells = new List<uint>(cellArray.Count);
for (int i = 0; i < cellArray.Count; i++) for (int i = 0; i < cellArray.Count; i++)
orderedCells.Add(cellArray[i]); orderedCells.Add(cellArray[i]);
_retailCellArrays[entityId] = orderedCells; _retailCellArrays[entityId] = orderedCells;
PublishRetailPartEntries(entityId, orderedCells, partArray); PublishRetailPartEntries(entityId, orderedCells, partArray);
RepublishAttachedChildren(entityId);
} }
/// <summary> /// <summary>
/// Recomputes <see cref="RecomputeRetailCellArray"/> at a new position /// Campaign OVERHAUL S2 chunk 4: publishes the retail render product from
/// when — and only when — a retail part array is currently retained for /// the SAME exact cell list the move-path collision republish
/// <paramref name="entityId"/>. No-op for every entity registered /// (<see cref="ReplacePositionRows"/>) just used — no second,
/// without one (item E of the S2 chunk-1 contract: existing callers stay /// independently-flooded cell array. Retail's
/// byte-for-byte unaffected). /// <c>CPhysicsObj::SetPositionInternal</c> (pc:283399) does exactly this
/// on a successful transition: <c>add_shadows_to_cells(this,
/// &amp;arg2-&gt;cell_array)</c> (pc:283536-283537) consumes the
/// TRANSITION's own array for BOTH the collision <c>CShadowObj</c> list
/// and the render <c>shadow_part_list</c> in one call — there is no
/// separate bbox/cylsphere dispatch on the move path
/// (<c>calc_cross_cells_static</c> only runs at registration, or at a
/// full recompute gated by the <c>HAS_PHYSICS_BSP_PS</c> state bit,
/// pc:283528, which acdream maps to <see cref="PhysicsShadowCommitAction.Recalculate"/>
/// → <see cref="UpdatePosition"/>, unaffected by this method).
/// A no-op (existing rows retained, matching retail's <c>num_cells &gt;
/// 0</c> 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.
/// </summary> /// </summary>
private void RecomputeRetailCellArrayIfPresent( private void PublishRetailProductFromExactCells(
uint entityId, uint entityId,
uint seedCellId, IReadOnlyList<uint> exactCells)
Vector3 worldPosition,
Quaternion worldRotation,
uint state,
bool isStatic)
{ {
if (!_entityRetailPartArrays.TryGetValue( if (!_entityRetailPartArrays.TryGetValue(
entityId, entityId,
@ -568,23 +558,23 @@ public sealed class ShadowObjectRegistry
{ {
return; return;
} }
// The retained multipart collision dispatch decides the route; a // The route is retail's calc_cross_cells_static DISPATCH decision
// single-shape registration retains none, which can only be a // (cylsphere vs bbox); the move path never re-runs that dispatch, so
// non-cylsphere object here (cylinder singles never move). // the route recorded at the last actual dispatch (registration, or a
IReadOnlyList<ShadowShape> collisionShapes = // Recalculate-driven full recompute) still describes it exactly.
_entityShapes.TryGetValue(entityId, out var retainedShapes) RetailCellArrayRoute route = _retailCellArrayRoutes.TryGetValue(
? retainedShapes entityId,
: Array.Empty<ShadowShape>(); out RetailCellArrayRoute existingRoute)
RecomputeRetailCellArray( ? existingRoute
entityId, seedCellId, worldPosition, worldRotation, state, : RetailCellArrayRoute.None;
collisionShapes, partArray, isStatic); PublishRetailCellArray(entityId, exactCells, route, partArray);
} }
/// <summary> /// <summary>
/// Removes every retained retail-cell-array product for /// Removes every retained retail-cell-array product for
/// <paramref name="entityId"/>: the part array, the route, the ordered /// <paramref name="entityId"/>: the part array, the route, the ordered
/// CELLARRAY, and every per-cell <see cref="RetailPartEntry"/> row it /// CELLARRAY, and every per-cell <see cref="RetailPartEntry"/> row it
/// published. The inverse of <see cref="RecomputeRetailCellArray"/>, /// published. The inverse of <see cref="PublishRetailCellArray"/>,
/// mirroring retail's <c>remove_shadows_from_cells</c> (0x00511230) /// mirroring retail's <c>remove_shadows_from_cells</c> (0x00511230)
/// symmetry for this side product. /// symmetry for this side product.
/// </summary> /// </summary>
@ -601,10 +591,12 @@ public sealed class ShadowObjectRegistry
/// <summary>Removes every <see cref="RetailPartEntry"/> owned by /// <summary>Removes every <see cref="RetailPartEntry"/> owned by
/// <paramref name="entityId"/> from each of <paramref name="cellIds"/>, /// <paramref name="entityId"/> from each of <paramref name="cellIds"/>,
/// reclaiming a cell's list once it is left empty.</summary> /// reclaiming a cell's list once it is left empty. Accepts
/// <see cref="IReadOnlyList{T}"/> so an attached child can share the
/// root's own cell list (chunk 4) without a defensive copy.</summary>
private void RemoveRetailPartEntriesFromCells( private void RemoveRetailPartEntriesFromCells(
uint entityId, uint entityId,
List<uint> cellIds) IReadOnlyList<uint> cellIds)
{ {
for (int i = 0; i < cellIds.Count; i++) for (int i = 0; i < cellIds.Count; i++)
{ {
@ -629,7 +621,7 @@ public sealed class ShadowObjectRegistry
/// </summary> /// </summary>
private void PublishRetailPartEntries( private void PublishRetailPartEntries(
uint entityId, uint entityId,
List<uint> orderedCells, IReadOnlyList<uint> orderedCells,
IReadOnlyList<ShadowShape> partArray) IReadOnlyList<ShadowShape> partArray)
{ {
bool clipPlanesRequired = orderedCells.Count > 1; bool clipPlanesRequired = orderedCells.Count > 1;
@ -657,7 +649,9 @@ public sealed class ShadowObjectRegistry
/// <summary> /// <summary>
/// The retail CELLARRAY retained for <paramref name="entityId"/> — see /// The retail CELLARRAY retained for <paramref name="entityId"/> — see
/// <see cref="RecomputeRetailCellArray"/>. Returns <see langword="false"/> /// <see cref="ComputeContractACellArray"/> (registration) and
/// <see cref="PublishRetailProductFromExactCells"/> (movement). Returns
/// <see langword="false"/>
/// when no retail part array was ever supplied for this entity (every /// when no retail part array was ever supplied for this entity (every
/// existing caller that omits the new trailing <c>partArray</c> parameter /// existing caller that omits the new trailing <c>partArray</c> parameter
/// on <see cref="Register"/>/<see cref="RegisterMultiPart"/>) or when the /// on <see cref="Register"/>/<see cref="RegisterMultiPart"/>) or when the
@ -698,6 +692,208 @@ public sealed class ShadowObjectRegistry
? route ? route
: RetailCellArrayRoute.None; : RetailCellArrayRoute.None;
/// <summary>
/// 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.
/// </summary>
private const int MaxAttachChainDepth = 64;
/// <summary>
/// Campaign OVERHAUL S2 chunk 4: retail's <c>CPhysicsObj::add_shadows_to_cells</c>
/// (0x00514ae0) child-inheritance recursion — "after the root,
/// add_shadows_to_cells recursively passes the same CELLARRAY to every
/// object in <c>children</c>, in child-list order"
/// (<c>oh1-construction-landscape-contract.md</c> Contract B).
/// acdream's attached projections (equipped weapons/shields/ammunition)
/// own no independent collision shapes, so this publishes PART ENTRIES
/// into every cell of <paramref name="rootEntityId"/>'s CURRENT retail
/// CELLARRAY — never a collision row. <paramref name="rootEntityId"/> is
/// the entity <paramref name="childEntityId"/> 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, <see cref="ReplaceMultiPartPayload"/>)
/// via <see cref="RepublishAttachedChildren"/>. A cycle or a chain deeper
/// than <see cref="MaxAttachChainDepth"/> is rejected — returns
/// <see langword="false"/> without mutating any state. Re-attaching an
/// already-attached child (a reparent) detaches it from its previous
/// parent first.
/// </summary>
public bool AttachChild(
uint childEntityId,
uint rootEntityId,
IReadOnlyList<ShadowShape> 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<uint>? siblings))
{
siblings = new List<uint>();
_parentChildren[rootEntityId] = siblings;
}
siblings.Add(childEntityId);
_childPartArrays[childEntityId] = childPartArray;
PublishChildEntries(childEntityId);
return true;
}
/// <summary>
/// Campaign OVERHAUL S2 chunk 4: the inverse of <see cref="AttachChild"/>
/// — retail's <c>remove_shadows_from_cells</c> (0x00511230) symmetry for
/// one attached child. Also detaches every descendant of
/// <paramref name="childEntityId"/> (a nested child of a child), matching
/// retail's recursion through <c>children</c> on removal too. No-op
/// (returns <see langword="false"/>) when <paramref name="childEntityId"/>
/// is not currently attached.
/// </summary>
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<uint>? 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<uint>? cells))
{
RemoveRetailPartEntriesFromCells(childEntityId, cells);
_retailCellArrays.Remove(childEntityId);
}
_childPartArrays.Remove(childEntityId);
_childParent.Remove(childEntityId);
if (removeFromParentList
&& _parentChildren.TryGetValue(parentId, out List<uint>? siblings))
{
siblings.Remove(childEntityId);
if (siblings.Count == 0)
_parentChildren.Remove(parentId);
}
return true;
}
/// <summary>
/// Validates an <see cref="AttachChild"/> call before any state
/// mutation: rejects a direct self-attach and any attach that would form
/// a cycle (<paramref name="childEntityId"/> already an ancestor of
/// <paramref name="parentId"/> through the existing chain), and bounds
/// the walk at <see cref="MaxAttachChainDepth"/>.
/// </summary>
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
}
/// <summary>
/// Walks <see cref="_childParent"/> from <paramref name="entityId"/> up
/// to its ultimate root (an id with no recorded parent), bounded at
/// <see cref="MaxAttachChainDepth"/>. Safe to call post-attach because
/// <see cref="TryValidateAttach"/> already proved the chain acyclic.
/// </summary>
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;
}
/// <summary>
/// (Re)publishes <paramref name="childEntityId"/>'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 <see cref="PublishRetailCellArray"/> applies
/// to the root itself.
/// </summary>
private void PublishChildEntries(uint childEntityId)
{
if (_retailCellArrays.TryGetValue(childEntityId, out List<uint>? previousCells))
{
RemoveRetailPartEntriesFromCells(childEntityId, previousCells);
_retailCellArrays.Remove(childEntityId);
}
if (!_childPartArrays.TryGetValue(childEntityId, out IReadOnlyList<ShadowShape>? partArray))
return;
uint root = ResolveAttachRoot(childEntityId);
if (root == childEntityId
|| !_retailCellArrays.TryGetValue(root, out List<uint>? rootCells)
|| rootCells.Count == 0)
{
return;
}
// Share the root's own list reference — PublishRetailCellArray never
// mutates a published List<uint> 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);
}
/// <summary>
/// Re-publishes every attached child of <paramref name="rootId"/> — DIRECT
/// children first (in child-list/attach order), each recursively followed
/// by its OWN attached children, matching retail's
/// <c>add_shadows_to_cells</c> depth-first recursion through
/// <c>children</c> exactly. Called from every place a root's retail cell
/// array is (re)published: <see cref="PublishRetailCellArray"/> (covers
/// registration and, via <see cref="PublishRetailProductFromExactCells"/>,
/// movement), <see cref="TryApplySetPosition"/> (staged apply), and
/// <see cref="ReplaceMultiPartPayload"/>. A no-op leaf call when
/// <paramref name="rootId"/> has no attached children.
/// </summary>
private void RepublishAttachedChildren(uint rootId)
{
if (!_parentChildren.TryGetValue(rootId, out List<uint>? children)
|| children.Count == 0)
{
return;
}
for (int i = 0; i < children.Count; i++)
{
uint childId = children[i];
PublishChildEntries(childId);
RepublishAttachedChildren(childId);
}
}
/// <summary> /// <summary>
/// Register a single-shape entity. <paramref name="seedCellId"/> is the /// Register a single-shape entity. <paramref name="seedCellId"/> is the
/// entity's <c>m_position.objcell_id</c> — the flood seed. Pass 0 to /// entity's <c>m_position.objcell_id</c> — the flood seed. Pass 0 to
@ -1029,6 +1225,13 @@ public sealed class ShadowObjectRegistry
{ {
RemoveRetailPartEntriesFromCells(entityId, retailCells); RemoveRetailPartEntriesFromCells(entityId, retailCells);
PublishRetailPartEntries(entityId, retailCells, partArray); 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; _ownerVersions[prepared.EntityId] = prepared.FinalOwnerVersion;
_lastAppliedSetPositionCommitId = prepared.CommitId; _lastAppliedSetPositionCommitId = prepared.CommitId;
_pendingSetPositionDispatches.Add(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( receipt = new SetPositionShadowCommitReceipt(
prepared.CommitId, prepared.CommitId,
prepared.EntityId, prepared.EntityId,
@ -2060,15 +2270,18 @@ public sealed class ShadowObjectRegistry
return; 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 _entityReg[entityId] = registration with
{ {
SeedCellId = seedCellId, SeedCellId = seedCellId,
EntityWorldPos = worldPosition, EntityWorldPos = worldPosition,
EntityWorldRot = worldRotation, EntityWorldRot = worldRotation,
}; };
RecomputeRetailCellArrayIfPresent(
entityId, seedCellId, worldPosition, worldRotation,
registration.State, registration.IsStatic);
BumpOwnerVersion(entityId); BumpOwnerVersion(entityId);
} }
@ -2171,9 +2384,14 @@ public sealed class ShadowObjectRegistry
if (withdrawn.Count == 0) if (withdrawn.Count == 0)
_withdrawnPrefixesByOwner.Remove(entityId); _withdrawnPrefixesByOwner.Remove(entityId);
} }
RecomputeRetailCellArrayIfPresent( // Campaign OVERHAUL S2 chunk 4: one array, two products. The retail
entityId, seedCellId, worldPosition, worldRotation, // render product is published from the SAME exact cell list collision
registration.State, registration.IsStatic); // 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); BumpOwnerVersion(entityId);
} }
@ -2470,9 +2688,30 @@ public sealed class ShadowObjectRegistry
BumpOwnerVersion(entityId); BumpOwnerVersion(entityId);
} }
/// <summary>Remove an entity from all cells it was registered in.</summary> /// <summary>
/// Remove an entity from all cells it was registered in. Campaign
/// OVERHAUL S2 chunk 4: mirrors retail's <c>remove_shadows_from_cells</c>
/// (0x00511230) full symmetry — a genuine teardown (as opposed to the
/// internal "clear then re-register" idiom <see cref="Register"/>/
/// <see cref="RegisterMultiPart"/> 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.
/// </summary>
public void Deregister(uint entityId) public void Deregister(uint entityId)
=> DeregisterCore(entityId, publishMutation: true); {
if (_parentChildren.TryGetValue(entityId, out List<uint>? 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) 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 — // Campaign OVERHAUL S2 chunk 3: seed the retail render product too —
// without this, a staging registry's own internal Contract-A // without this, a staging registry's own internal move-path publish
// recompute (RecomputeRetailCellArrayIfPresent, reached through // (PublishRetailProductFromExactCells, reached through
// CommitSetPosition) finds nothing retained and silently no-ops, // CommitSetPosition) finds nothing retained and silently no-ops,
// dropping the retail product for every entity that moves through // dropping the retail product for every entity that moves through
// the staged TryPrepareSetPosition/TryApplySetPosition pipeline. // the staged TryPrepareSetPosition/TryApplySetPosition pipeline.
@ -3428,6 +3667,17 @@ public sealed class ShadowObjectRegistry
_prefixScratch.Clear(); _prefixScratch.Clear();
_removedPrefixScratch.Clear(); _removedPrefixScratch.Clear();
_pendingSetPositionDispatches.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; _fallback = null;
} }

View file

@ -1388,7 +1388,9 @@ public sealed class LiveEntityNetworkOnPositionCollapseMatrixTests
static (_, _, _) => static (_, _, _) =>
new ExactProjectionWithdrawalOutcome( new ExactProjectionWithdrawalOutcome(
ExactProjectionWithdrawalDisposition.Superseded, ExactProjectionWithdrawalDisposition.Superseded,
null)), null),
Shadows,
new PhysicsDataCache()),
new ProjectileController(Runtime), new ProjectileController(Runtime),
animatedEntities, animatedEntities,
new RemoteMovementObservationTracker(), new RemoteMovementObservationTracker(),

View file

@ -653,7 +653,9 @@ public sealed class LiveEntityNetworkRemoteTeleportPresentationTests
static (_, _, _) => static (_, _, _) =>
new ExactProjectionWithdrawalOutcome( new ExactProjectionWithdrawalOutcome(
ExactProjectionWithdrawalDisposition.Superseded, ExactProjectionWithdrawalDisposition.Superseded,
null)), null),
Shadows,
new PhysicsDataCache()),
new ProjectileController(Runtime), new ProjectileController(Runtime),
animatedEntities, animatedEntities,
new RemoteMovementObservationTracker(), new RemoteMovementObservationTracker(),

View file

@ -9,6 +9,7 @@ using AcDream.Content;
using AcDream.Core.Items; using AcDream.Core.Items;
using AcDream.Core.Net; using AcDream.Core.Net;
using AcDream.Core.Net.Messages; using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using AcDream.Core.World; using AcDream.Core.World;
using DatReaderWriter.DBObjs; using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums; using DatReaderWriter.Enums;
@ -1767,7 +1768,9 @@ public sealed class EquippedChildProjectionWithdrawalTests
Live, Live,
Poses, Poses,
update => Live.TryApplyParent(update, out _), update => Live.TryApplyParent(update, out _),
withdraw); withdraw,
new ShadowObjectRegistry(),
new PhysicsDataCache());
_lifecycle.Bind(new DelegateLiveEntityRuntimeComponentLifecycle( _lifecycle.Bind(new DelegateLiveEntityRuntimeComponentLifecycle(
Controller.OnLogicalTeardown)); Controller.OnLogicalTeardown));
} }

View file

@ -231,16 +231,56 @@ public sealed class WalkProductionWorldDataTests
Assert.Empty(outdoor); 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] [Fact]
public void ResolveDynamicRenderCells_EquippedDescendantInheritsRootCellArray() public void ResolveDynamicRenderCells_AttachedChildReadsTheRegistrysRetailCellArrayDirectly()
{ {
var shadows = new ShadowObjectRegistry();
const uint rootId = 0x5000000Au;
const uint childId = 0x800045EEu;
IReadOnlyList<ShadowShape> 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<uint> 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( RenderProjectionRecord wand = Record(
id: 0x800045EEu, id: childId,
position: Vector3.Zero) with position: Vector3.Zero) with
{ {
Source = new RenderSourceMetadata() with Source = new RenderSourceMetadata() with
{ {
LocalEntityId = 0x800045EEu, LocalEntityId = childId,
ParentCellId = 0xF4180104u, ParentCellId = 0xF4180104u,
}, },
EntityPayload = new RenderEntityPayload() with EntityPayload = new RenderEntityPayload() with
@ -248,27 +288,66 @@ public sealed class WalkProductionWorldDataTests
CasterIdentity = RenderCasterIdentityKind.EquippedChild, CasterIdentity = RenderCasterIdentityKind.EquippedChild,
}, },
}; };
var owners = new Dictionary<uint, IReadOnlyList<uint>>
{
[0x5000000Au] = [0xF4180106u, 0xF4180104u],
};
var parents = new Dictionary<uint, uint>
{
[0x800045EEu] = 0x80000461u,
[0x80000461u] = 0x5000000Au,
};
IReadOnlyList<uint> cells = IReadOnlyList<uint> cells =
WalkProductionWorldData.ResolveDynamicRenderCells( WalkProductionWorldData.ResolveDynamicRenderCells(
in wand, in wand,
id => owners.TryGetValue(id, out IReadOnlyList<uint>? value) id => shadows.TryGetRetailCellArray(id, out IReadOnlyList<uint> c)
? value ? (true, c)
: Array.Empty<uint>(), : (false, Array.Empty<uint>()),
id => parents.TryGetValue(id, out uint value) shadows.GetOwnerCells,
? value out bool usedFallback);
: null);
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<ShadowShape> 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<uint> cells =
WalkProductionWorldData.ResolveDynamicRenderCells(
in record,
id => shadows.TryGetRetailCellArray(id, out IReadOnlyList<uint> c)
? (true, c)
: (false, Array.Empty<uint>()),
shadows.GetOwnerCells,
out bool usedFallback);
Assert.True(usedFallback);
Assert.Equal(shadows.GetOwnerCells(entityId), cells);
} }
// ----------------------------------------------------------------- // -----------------------------------------------------------------

View file

@ -294,8 +294,17 @@ public class ShadowObjectRegistryRetailCellArrayTests
e => e.EntityId == entityId); 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] [Fact]
public void CommitSetPosition_WithRetainedPartArray_RecomputesRetailCellArray() public void CommitSetPosition_NoneAction_PublishesRetailProductFromTheRetainedCells()
{ {
var reg = new ShadowObjectRegistry(); var reg = new ShadowObjectRegistry();
const uint entityId = 0x16u; const uint entityId = 0x16u;
@ -307,7 +316,13 @@ public class ShadowObjectRegistryRetailCellArrayTests
Assert.True(reg.TryGetRetailCellArray(entityId, out var before)); Assert.True(reg.TryGetRetailCellArray(entityId, out var before));
Assert.Equal(new[] { CellA }, 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( reg.CommitSetPosition(
entityId, entityId,
moved, moved,
@ -318,8 +333,72 @@ public class ShadowObjectRegistryRetailCellArrayTests
action: PhysicsShadowCommitAction.None, action: PhysicsShadowCommitAction.None,
crossCellIds: ImmutableArray<uint>.Empty); crossCellIds: ImmutableArray<uint>.Empty);
Assert.Equal(new[] { CellA }, reg.GetOwnerCells(entityId));
Assert.True(reg.TryGetRetailCellArray(entityId, out var after)); 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<ShadowShape> 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<ShadowShape> 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<uint>.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.Single(partEntries);
Assert.Equal(0x8101u, partEntries[0].GfxObjId); 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<ShadowShape> 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<ShadowShape> 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<ShadowShape> 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<ShadowShape> 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<ShadowShape> 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<ShadowShape> 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<ShadowShape> 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<ShadowShape> 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<ShadowShape> 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);
}
} }