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

@ -38,9 +38,10 @@ One transaction per object (`CPhysicsObj::calc_cross_cells_static`
|---|---|---|---|---|
| **Chunk 3 (2026-09-02).** `LandblockPhysicsPublisher.PublishStaticEntity``ShadowObjectRegistry.RegisterMultiPart` (`LandblockPhysicsPublisher.cs:930-1079`, `ShadowObjectRegistry.cs`, `RegisterMultiPart`/`Register`/`ComputeContractACellArray`/`PublishRetailCellArray`) | the WHOLE visual part array (`ShadowShapeBuilder.FromStaticRenderParts`, chunk 1b) drives ONE Contract A flood (cylsphere route from the collision shapes' cylinders, else bbox route over the part array); a caller with no part array (every unit-test fixture, `partArray ?? shapes`) keeps the pre-chunk-3 legacy dispatch byte-for-byte | `_entityToCells[id]`/`_cells[cell]` (collision) AND `_retailCellArrays`/`_retailPartEntriesByCell` (render) from the SAME array — the second (redundant) flood chunk 1 left running is deleted | once per static at publication | physics broadphase `TransitionTypes.FindObjCollisionsInCell` (`TransitionTypes.cs:3755-3779`); `WalkProductionWorldData` (below) |
| **DELETED (chunk 2).** Was `WalkProductionWorldData.ResolveStaticRenderCells``ShadowObjectRegistry.ComputeStaticRenderCells`. Now: `WalkProductionWorldData.ResolveIndoorStaticRenderCells`/`ResolveOutdoorStaticRenderCells``ShadowObjectRegistry.TryGetRetailCellArray` (`WalkProductionWorldData.cs`, `ShadowObjectRegistry.cs:634-663`) | none (borrowed) | chunk 1's retained per-entity retail CELLARRAY, now the SAME array chunk 3 also uses for collision | every `BeginFrame` sweep of `RenderSceneIndex.IndoorCellStatic`/`OutdoorStatic` (a borrow, not a recompute) | `WalkFrameDriver.GetCellStatics`; `RetailPViewRenderer` particle owner union; unregistered fallback (indoor: authored `ParentCellId`; outdoor: `GetOwnerCells`) counted by `WalkProductionWorldData.UnregisteredStaticRenderFallbackCount` |
| **Chunk 3 (2026-09-02).** `LiveEntityCollisionBuilder.Register` / `ShadowObjectRegistry.UpdatePosition` / `CommitSetPosition` (`LiveEntityCollisionBuilder.cs:149-280`, `ShadowObjectRegistry.cs`) | `ShadowShapeBuilder.FromSetup` (collision dispatch) + `FromSetupRenderParts` (chunk 1b's part array) → the same ONE Contract A flood as above at registration; movement (`UpdatePosition`/`CommitSetPosition`'s `Recalculate` action) forwards to the same registration entry points, so it inherits the fix. `CommitSetPosition`'s `None`/`Preserve`/`Replace` actions (`RefreshPositionRows`/`ReplacePositionRows`) are UNCHANGED — they consume an externally-decided collision cell list and separately recompute the retail product via the same Contract A primitive on every move (chunk 1's design, not a second registration flood) | `_entityToCells` + `_cells`, and (when a part array is retained) `_retailCellArrays`/`_retailPartEntriesByCell` | spawn and every accepted move/appearance change | physics broadphase; `WalkProductionWorldData` dynamic render lookup |
| **Chunk 3 (2026-09-02).** The staged `ShadowObjectRegistry.TryPrepareSetPosition`/`TryApplySetPosition` pipeline | `PreparedShadowOwnerState` now also carries `RetailPartArray`/`RetailCellIds`/`RetailRoute`/`RetailRows`; `TryCaptureOwnerState`/`InstallOwnerState` seed them so the staging registry's own `RecomputeRetailCellArrayIfPresent` call (reached through `CommitSetPosition`) no longer silently no-ops; `PrepareRetailPartEntryReplacements` diffs the staged retail rows the same way `PrepareCellReplacements` diffs collision rows | `_retailPartEntriesByCell` publishes alongside `_cells` on `TryApplySetPosition` | every live entity moving through the transactional SetPosition publication tail (Runtime) | same as direct `CommitSetPosition`, now consistent with it |
| `WalkProductionWorldData.ResolveDynamicRenderCells` (`WalkProductionWorldData.cs:247-283`) | `GetOwnerCells`; for an `EquippedChild` with no cells, a 64-hop parent-chain walk | whole-record buckets | every `BeginFrame` | `WalkFrameDriver.GetCellDynamics` |
| **Chunk 4 (2026-09-02).** `LiveEntityCollisionBuilder.Register` / `ShadowObjectRegistry.UpdatePosition` / `CommitSetPosition` (`LiveEntityCollisionBuilder.cs:149-280`, `ShadowObjectRegistry.cs`) | `ShadowShapeBuilder.FromSetup` (collision dispatch) + `FromSetupRenderParts` (chunk 1b's part array) → the same ONE Contract A flood as above at registration; movement (`UpdatePosition`/`CommitSetPosition`'s `Recalculate` action) forwards to the same registration entry points, so it inherits the fix. **RESOLVED (chunk 4):** `CommitSetPosition`'s `None`/`Preserve`/`Replace` actions (`RefreshPositionRows`/`ReplacePositionRows`) now publish the retail render product from the SAME exact collision cell list the move just used (`PublishRetailProductFromExactCells`) — the independent Contract A recompute chunk 1/3 ran on every move (`RecomputeRetailCellArrayIfPresent`) is deleted. Mirrors retail `SetPositionInternal`'s `add_shadows_to_cells(this, &arg2->cell_array)` (pc:283536-283537), which consumes the transition's own array for both products in one call; the `num_cells > 0` keep-when-empty gate (pc:283540) now covers both products identically | `_entityToCells` + `_cells`, and (when a part array is retained) `_retailCellArrays`/`_retailPartEntriesByCell`, both from the SAME exact cell list on every move | spawn and every accepted move/appearance change | physics broadphase; `WalkProductionWorldData` dynamic render lookup (now a direct `TryGetRetailCellArray` read, see below) |
| **Chunk 4 (2026-09-02).** The staged `ShadowObjectRegistry.TryPrepareSetPosition`/`TryApplySetPosition` pipeline | `PreparedShadowOwnerState` carries `RetailPartArray`/`RetailCellIds`/`RetailRoute`/`RetailRows`; `TryCaptureOwnerState`/`InstallOwnerState` seed them so the staging registry's own `CommitSetPosition` call inherits chunk 4's one-array-two-products move path for free; `PrepareRetailPartEntryReplacements` diffs the staged retail rows the same way `PrepareCellReplacements` diffs collision rows; `TryApplySetPosition` additionally calls `RepublishAttachedChildren` on the LIVE registry after publishing, since the staging registry carries none of the live attach-chain state | `_retailPartEntriesByCell` publishes alongside `_cells` on `TryApplySetPosition`; every attached child of the moved root republishes too | every live entity moving through the transactional SetPosition publication tail (Runtime) | same as direct `CommitSetPosition`, now consistent with it |
| **Chunk 4 (2026-09-02).** `ShadowObjectRegistry.AttachChild`/`DetachChild` (`ShadowObjectRegistry.cs`) — retail's `add_shadows_to_cells` child-inheritance recursion (Contract B) | `EquippedChildRenderController.TryRealize` calls `AttachChild(childEntityId, rootEntityId, childPartArray)` at the exact moment the attached projection's `WorldEntity` is registered against its accepted parent, using `ShadowShapeBuilder.FromSetupRenderParts` over the SAME `PhysicsDataCache` resolvers `LiveEntityCollisionBuilder` uses; `CommitProjectionRemoval` (the single withdrawal/unparent/teardown funnel) calls `DetachChild`. A nested attachment (child of a child) resolves to the ultimate root by walking the existing chain, bounded and cycle-safe | new `_childParent`/`_parentChildren`/`_childPartArrays` dictionaries (own `CollisionWorldState` fields, reset with everything else); PART ENTRIES only for the child — never a collision row, since acdream's attached projections own no independent collision shapes | attach/detach, and every time the root's array republishes (registration, move, staged apply, `ReplaceMultiPartPayload`) | `WalkProductionWorldData.ResolveDynamicRenderCells` (a plain `TryGetRetailCellArray` read on the CHILD's own id — no parent walk) |
| **RESOLVED (chunk 4).** `WalkProductionWorldData.ResolveDynamicRenderCells` (`WalkProductionWorldData.cs`) | `ShadowObjectRegistry.TryGetRetailCellArray` directly, for every dynamic record including equipped children (their array is published at attach time by the registry, above); unregistered fallback is `GetOwnerCells`, counted by the SAME `UnregisteredStaticRenderFallbackCount` the static buckets use — the render-side 64-hop parent-chain walk and its `FindParentLocalId` plumbing into this class are deleted | whole-record buckets | every `BeginFrame` | `WalkFrameDriver.GetCellDynamics` |
| `LightManager.SelectForCell` (`LightManager.cs:594-650`) | flat `_all` list, sphere overlap | per-cell light subset | per cell drawn | lighting (S5 owns; retail selects from the current EnvCell + stab list) |
| `DirectionalShadowCasterFrame` (`DirectionalShadowCasterFrame.cs:229-233`) | flat outdoor indices, no cell filter | caster list | per frame | shadow pass (S5) |
| Topology: `EnvCellLandblockBuildBuilder.BuildVisibilityCell` vs `WalkCellFactory.FromParsed` vs `FlatEnvCellTopology` | same DAT / pak | three distinct portal graphs (`LoadedCell`, `WalkCell`, flat asset) | per landblock build | render, walk, `CellTransit` |
@ -54,8 +55,12 @@ Findings that shape the chunks:
registration that carries a real part array.
- Render membership is whole-object per cell; retail's is every part per
cell (identity retained per part, with cell frame and clip planes).
- Children are inherited only on the render side, at bucket time; retail
inherits at registration.
- **RESOLVED (chunk 4).** Children were inherited only on the render side, at
bucket time (a 64-hop parent-chain walk); retail inherits at registration
(`add_shadows_to_cells`'s recursion through `children`). Now
`ShadowObjectRegistry.AttachChild` owns the inheritance at the registry, at
attach time, and re-propagates on every root array change; the render-side
walk is deleted.
- `b3b7d922` removed the indoor-seed stab-list prune inside the shared
`BuildShadowCellSetFromParts`, affecting every caller. Retail has no such
prune in the bbox route (Contract A), so the removal is the retail
@ -106,9 +111,11 @@ gates: Core physics suites, the placement/collision replay fixtures, and the
connected nine-stop route; `CellTransit`'s prune removal is confirmed
against Contract A.
**Chunk 4 — dynamics and children.** `add_shadows_to_cells` recursion:
children register with the root's CELLARRAY at registration and movement;
particles own-cell only; the render-side parent-chain walk is deleted.
**Chunk 4 (landed 2026-09-02) — dynamics and children.** `add_shadows_to_cells`
recursion: children register with the root's CELLARRAY at registration and
movement; particles own-cell only (still no production path reaches this
registry — chunk 3's finding stands, deferred); the render-side parent-chain
walk is deleted. See §7.
**Chunk 5 — consumer cutover and deletion.** Remaining `WalkProductionWorldData`
buckets become borrowed views; particle owner-cell union reads the same;
@ -328,3 +335,147 @@ branch to implement in this registry today; the retail
`add_particle_shadow_to_cell` own-cell-only rule remains chunk 4 (or later)
territory, to be implemented if/when particle emitters are ever routed
through this registry.
## 7. Chunk 4 evidence (2026-09-02)
**Move-path truth confirmed in the pseudo-C (item A).** `CPhysicsObj::SetPositionInternal`
`0x00515330` (`docs/research/named-retail/acclient_2013_pseudo_c.txt` lines
283399-283543) proves the movement product is retail's SAME
`add_shadows_to_cells` call the registration path uses — not a second,
independently-flooded array:
```text
if (this->cell != 0)
{
if ((this->state & 0x10000) != 0)
{
CPhysicsObj::calc_cross_cells(this); // full recompute (pc:283530)
return 1;
}
if (arg2->cell_array.num_cells > 0) // keep-when-empty gate (pc:283534/283540)
{
CPhysicsObj::remove_shadows_from_cells(this);
CPhysicsObj::add_shadows_to_cells(this, &arg2->cell_array); // pc:283536-283537
}
// else: neither product is touched — both stay exactly as they were.
}
```
`arg2` is the `CTransition` the sphere-path walk already produced; its
`cell_array` is the SAME array retail's collision system computed during the
move — not Contract A's `calc_cross_cells_static` dispatch (that only runs at
registration, or via the `state & 0x10000` full-recompute branch, which
acdream already maps to `PhysicsShadowCommitAction.Recalculate`
`UpdatePosition`, unaffected by this chunk). `CPhysicsObj::calc_cross_cells`
`0x00515230` (lines 283332-283395) confirms the full-recompute branch is a
DIFFERENT primitive (its own cylsphere/bbox dispatch, ending in the same
`remove_shadows_from_cells` + `add_shadows_to_cells` pair) — chunk 4 leaves it
untouched.
**Chunk 4 changes, exactly as the pseudo-C requires:**
- `ShadowObjectRegistry.ReplacePositionRows` now calls the new
`PublishRetailProductFromExactCells(entityId, exactCells)` — the SAME
`exactCells` list collision just republished into — instead of
`RecomputeRetailCellArrayIfPresent`'s independent Contract A flood.
`PublishRetailProductFromExactCells` is a thin wrapper over the existing
`PublishRetailCellArray`, reusing the entity's LAST recorded dispatch route
(registration never re-runs on a move, matching the pseudo-C: no
`calc_cross_cells_static` call anywhere in `SetPositionInternal`'s
non-full-recompute branch).
- `ShadowObjectRegistry.RefreshPositionRows`'s "nothing retained at all"
branch (both `_entityToCells` and `_suspendedEntityCells` empty) no longer
calls anything — true keep-when-empty, matching the pseudo-C's `else`
(neither product touched).
- `RecomputeRetailCellArrayIfPresent` and the now-unreachable
`RecomputeRetailCellArray` are deleted outright (S2 plan rule "no
competing production owner").
- **Scope-decision reversal from chunk 3's §6 open question:** chunk 3 left
the move path as two legitimately-different computations (transition
cross-cells vs. Contract A's part-array flood) and flagged it for chunk 4's
retail review. The pseudo-C resolves it: retail's OWN move path never runs
Contract A at all; it reuses the transition's array for both products. The
"two computations" framing was itself the deviation from retail — chunk 4
removes it, not preserves it.
- **Test consequence:** `ShadowObjectRegistryRetailCellArrayTests.CommitSetPosition_WithRetainedPartArray_RecomputesRetailCellArray`
encoded exactly the two-source behavior this chunk removes (a `.None`
commit whose retail array independently flooded to the entity's NEW world
position while collision stayed at the OLD retained cell). It is replaced
by `CommitSetPosition_NoneAction_PublishesRetailProductFromTheRetainedCells`
(retail array == `GetOwnerCells`, both at the retained cell) plus two new
fixtures covering `.Replace` (both move to the transition's cross cells)
and the direct-path keep-when-empty case. The new invariant, pinned across
every `CommitSetPosition` variant and the staged apply: **retail array ==
`GetOwnerCells` after every move.**
**Children (item B/C), landed exactly as designed.** `ShadowObjectRegistry`
gains `AttachChild(childEntityId, rootEntityId, childPartArray)` /
`DetachChild(childEntityId)`, backed by three new `CollisionWorldState`
dictionaries (`ShadowChildParent`, `ShadowParentChildren`,
`ShadowChildPartArrays` — reset by `AttachCollisionWorld`'s state swap and by
`Clear()` exactly like every other per-entity table). `AttachChild` publishes
PART ENTRIES only (never a collision row — acdream's attached projections own
no independent collision shapes) into every cell of the root's CURRENT
`_retailCellArrays` entry; a nested attachment (child of a child) resolves to
the ultimate root by walking the existing `_childParent` chain, bounded at 64
hops and rejecting a cycle before any state mutates. `RepublishAttachedChildren`
is called from every site that publishes a root's retail cell array —
`PublishRetailCellArray` (covers registration and, via
`PublishRetailProductFromExactCells`, movement), `TryApplySetPosition`
(staged apply, since the staging registry that computed the prepared commit
carries none of the live registry's attach-chain state), and
`ReplaceMultiPartPayload` (defensive completeness; the root's cell SET never
actually changes there, so this is a structural no-op in practice) — and
recurses depth-first through `_parentChildren` in attach order, matching
`add_shadows_to_cells`'s exact recursion through `children`. `Deregister`
cascades a genuine teardown through every attached child (guarded so the
INTERNAL "clear then re-register" idiom `Register`/`RegisterMultiPart` use
ahead of their own flood — `DeregisterCore(..., publishMutation: false)`
never detaches children it should be re-publishing to instead).
**App wiring (item C).** `EquippedChildRenderController.TryRealize` — the one
seam that both `OnCreateParentAccepted` and `OnParentEvent` funnel through to
materialize an attached projection's `WorldEntity` — calls `AttachChild`
right after `_attachedByChild[childKey] = attached`, using a new
`BuildChildRenderParts` helper: `ShadowShapeBuilder.FromSetupRenderParts`
over the child's `Setup` + its post-`AnimPartChanged` `template` (already
built by `BuildPartTemplate` for the pose composition) + the controller's own
new `PhysicsDataCache` field — the SAME resolver pair
`LiveEntityCollisionBuilder.Build` uses, wired from the SAME composition
point (`d.PhysicsDataCache`, `LivePresentationComposition.cs`) that already
supplies `LiveEntityCollisionBuilder`. `CommitProjectionRemoval` — the single
funnel every withdrawal/unparent/teardown path in the file commits removal
through — calls `DetachChild(child.Entity.Id)`. No Core→App dependency: both
`ShadowObjectRegistry` and `PhysicsDataCache` are Core types the App layer
already depends on; the controller (App) references Core, never the reverse.
`WalkProductionWorldData.ResolveDynamicRenderCells` is now a direct
`TryGetRetailCellArray` read (the same recipe `ResolveOutdoorStaticRenderCells`
already used for statics) for EVERY dynamic record, children included — no
special-casing on `RenderCasterIdentityKind.EquippedChild` any more, since the
registry now answers correctly for a child on its own id. The render-side
64-hop parent-chain walk, its `_findParentLocalId` field, and the
`findParentLocalId` constructor parameter threaded through
`WalkProductionWorldData``RetailPViewRenderer``FrameRootComposition`
are deleted; `EquippedChildRenderController.FindParentLocalId` itself is
UNCHANGED and keeps serving its other caller (`EntityEffectController`'s pose
composition, `LivePresentationComposition.cs`). An entity with no retail
array yet falls back to `GetOwnerCells`, counted by the SAME
`UnregisteredStaticRenderFallbackCount` the static buckets already use (item
C's explicit requirement — one fallback counter, not a second one for
dynamics).
**Particles (item D).** Unchanged from chunk 3's finding: no production code
path sets the particle-emitter state bit or routes an emitter through
`ShadowObjectRegistry` at all (`AcDream.App/Rendering/ParticleRenderer.cs`
owns emitter placement independently via a GPU mesh-reference table). Left as
a documented chunk-5-or-later item; this chunk invents no path for it.
**Automated evidence.** Core suite `Lane!=Timing&Lane!=Linux`: 4,970/4,970 (was
4,961; +9 new `ShadowObjectRegistryRetailCellArrayTests` fixtures — 2 replacing
the retargeted move-path test, 7 covering `AttachChild`/`DetachChild`,
nesting, and cycle rejection). App suite (hermetic lanes): 6,761/6,761 (was
6,760; net +1 — the old `ResolveDynamicRenderCells_EquippedDescendantInheritsRootCellArray`
parent-chain-walk test replaced by two direct-registry tests). Runtime suite:
1,884/1,884 (unchanged — chunk 4 touched no Runtime source). Full solution
Release build: 0 warnings, 0 errors.