acdream/docs/research/2026-09-01-overhaul/s2-membership-ownership-map.md
Erik 75ea269d35 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>
2026-09-02 23:37:34 +02:00

36 KiB
Raw Blame History

S2 plan packet — cell-membership ownership map and chunk contracts

Date: 2026-09-02. Campaign OVERHAUL S2 (world graph: one membership owner). Retail contract: oh1-construction-landscape-contract.md Contracts A and B. Current-state evidence: read-only code trace (file:line) taken 2026-09-02 after S1 closed at 8c6563ca.

1. Retail truth this slice ports

One transaction per object (CPhysicsObj::calc_cross_cells_static 0x00515160, then remove_shadows_from_cells 0x00511230 + add_shadows_to_cells 0x00514ae0):

  1. CELLARRAY is computed from the whole part array. Cylsphere route when (state & 0x10000) == 0 && part_array != null && GetNumCylsphere() != 0; otherwise the bbox route (find_bbox_cell_list 0x00510fc0): seed the object's cell, then for every array cell call CEnvCell::find_transit_cells 0x0052cae0, which per portal and per VISUAL part uses gfxobj->physics_sphere else drawing_sphere for the plane reject, the part bounding box (CPhysicsPart::GetBoundingBox) for Plane::intersect_box and box_intersects_cell, and add_all_outside_cells once for an 0xFFFFFFFF portal. Insertion order is retained; nothing prunes outside cells afterwards.
  2. Two products of that one array, in the same order: per cell a CShadowObj (collision row, add_shadow_object) and, when a part array exists, CPartArray::AddPartsShadow 0x00517e40, which adds EVERY part to that cell's shadow_part_list with planes = num_shadow_objects > 1 ? cell->clip_planes : null and the cell frame. Per-part visibility is NOT a membership subset; it is decided at draw time by the portal view test and the drawn stamp (S3).
  3. Children receive the root's same CELLARRAY recursively in child-list order. Particles (state & 0x1000) register only in their own cell.
  4. Removal is the inverse transaction at the same owner.

2. What acdream does today (trace, 2026-09-02)

Owner Inputs Output Runs Consumers
Chunk 3 (2026-09-02). LandblockPhysicsPublisher.PublishStaticEntityShadowObjectRegistry.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.ResolveStaticRenderCellsShadowObjectRegistry.ComputeStaticRenderCells. Now: WalkProductionWorldData.ResolveIndoorStaticRenderCells/ResolveOutdoorStaticRenderCellsShadowObjectRegistry.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 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

Findings that shape the chunks:

  • The two static floods are the SAME CellTransit walk with DIFFERENT part inputs (BSP-only vs all visual parts). Retail uses all parts once. The render-side flood already carries retail's inputs; the collision side does not. RESOLVED (chunk 3): one Contract A flood now drives both, for every registration that carries a real part array.
  • Render membership is whole-object per cell; retail's is every part per cell (identity retained per part, with cell frame and clip planes).
  • 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 direction; it is re-verified, not re-litigated, in chunk 3.
  • Topology triplication is real but the walk is its consumer; consolidating it belongs with S3's WalkPView state, not S2. S2 owns membership only.

3. Chunks

Chunk 1 — the retail CELLARRAY and the per-part render product, beside the existing owners (zero pixel change). Core ShadowObjectRegistry gains a registration input for the whole part array (per part: GfxObj id, part frame, physics sphere else drawing sphere, part box) and computes retail's CELLARRAY from it as a new retained RetailCellArray(entityId); it also records per-cell part entries (entityId, partIndex, gfxObjId, cell frame, clipPlanes-required) in registration order. Existing _entityToCells, _cells, and every consumer are untouched. App (LandblockPhysicsPublisher) and Runtime (LiveEntityCollisionBuilder) supply the part array at the same call. A comparator test over installed-DAT fixtures (Facility stair Setup 0x02000623 in 0x8A02015F, cathedral ramp 0x020009A2, the #334 Neftet formation, an outdoor landblock-edge static, a multi-part Setup) reports the three answers side by side: retail CELLARRAY, current collision cells, current render cells. Differences are evidence for chunks 2/3, not failures.

Chunk 2 — render statics cut over (landed 2026-09-02). WalkProductionWorldData indoor and outdoor static buckets are filled whole-record-per-cell from the registry's retail CELLARRAY (TryGetRetailCellArray), not from its per-cell part entries: per-part draw admission is S3's job, and today's draw path still consumes whole RenderProjectionRecords, so grouping GetRetailPartEntriesInCell back into records here would be a no-op detour. ResolveStaticRenderCells, ComputeStaticRenderCells, the fingerprint cache (_staticRenderCellCache + its revision/generation invalidation), the primitiveSetup special case, and WalkProductionWorldData's use of ShadowShapeBuilder.FromStaticRenderParts are deleted. An entity with no retail array yet (the streaming-window race between the static-projection journal and the physics publisher — AD-40's residency reasoning) falls back to a conservative single-cell membership (indoor: authored ParentCellId; outdoor: today's GetOwnerCells), counted by the new WalkProductionWorldData.UnregisteredStaticRenderFallbackCount for chunk 5 to weigh against the connected-route count. The Facility stair pin (Issue177StairDescentCameraFloodTests.FacilityStairAssembly_*) moved to registering through the production RegisterMultiPart(..., partArray:) + TryGetRetailCellArray recipe.

Chunk 3 — collision cut over. _entityToCells/_cells are populated from the retail CELLARRAY (collision shapes into every array cell). Physics 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 (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; duplicate dictionaries and fallbacks are deleted; architecture text updated. Lights and directional shadows stay as they are (S5).

Owner gate G2 after chunk 5 (plan §9 S2).

4. Tests that pin current behavior (must be consciously replaced, not

silently broken)

WalkProductionWorldDataTests (bucketing), Issue177StairDescentCameraFloodTests. FacilityStairAssembly_RegisterAcross015FTo015EWithoutCollisionRows, the CellTransit* and BuildShadowCellSetTests families, ShadowObjectRegistry* and ShadowShapeBuilder*, LiveEntityCollisionBuilderTests, LandblockPhysicsPublisherTests, Issue334*, the flood replay fixtures, and the Runtime placement/physics suites (full list in the 2026-09-02 trace).

5. Chunk 1 evidence (2026-09-02, chunk 1b)

Chunk 1b threaded the whole visual part array through every production RegisterMultiPart/ReplaceMultiPartPayload caller as a zero-pixel-change side product:

  • LandblockPhysicsPublisher.PublishStaticEntity — builds ShadowShapeBuilder.FromStaticRenderParts(entity.MeshRefs, publication.StagingCache.GetGfxObj, publication.StagingCache.GetVisualBounds, out _) once per static and passes it as partArray: to BOTH the BSP-branch and the Setup cylinder/sphere fallback-branch RegisterMultiPart calls. The visual-bounds source is publication.StagingCache, the SAME PhysicsDataCache WalkProductionWorldData.ResolveStaticRenderCells already reads (cache.GetGfxObj/cache.GetVisualBounds) — no new source was threaded, none was needed.
  • LandblockPhysicsContentBuilder.PublishStaticCollision (the no-window/ Headless twin, driven from HeadlessSessionWorldProjection) — same treatment, same cache.GetGfxObj/cache.GetVisualBounds pair.
  • LiveEntityCollisionBuilder — added ShadowShapeBuilder.FromSetupRenderParts (Core, mirrors FromStaticRenderParts's per-part dispatch but walks a Setup's parts directly instead of MeshRefs, since a live entity has no meshRef list). LiveEntityCollisionRegistration gained a RenderParts field; Build() computes it via two new resolvers (Func<uint, GfxObjPhysics?> _getGfxObj, Func<uint, GfxObjVisualBounds?> _getVisualBounds) that the public constructor wires to PhysicsDataCache.GetGfxObj/GetVisualBounds — the identical resolver SHAPE FromStaticRenderParts takes, so a static and a live Setup sharing a GfxObj id read the same render geometry. Both new constructor parameters default to a no-op resolver, so every existing 2-arg test fixture keeps its exact prior behavior (RenderParts resolves to an empty list, entering RecomputeRetailCellArray's partArray.Count == 0 early return — a no-op on every dictionary chunk 1a introduced). Register/ReconcileAppearance pass partArray: registration.RenderParts into RegisterMultiPart/ ReplaceMultiPartPayload.
  • No other production caller exists: a full-source grep for .RegisterMultiPart(/.ReplaceMultiPartPayload( after this change finds exactly these four call sites (App publisher ×2 branches, Content builder ×2 branches, Runtime builder ×2 methods) plus the four in this list — every one now supplies partArray:.
  • Zero-behavior-change proof: App.Tests filtered to the non-InstalledDat lanes still passes exactly 6,758/6,758 (unchanged count), Core.Tests Physics 2,202/2,202, Runtime.Tests 1,884/1,884 — chunk 1b changed no observable production output.

Installed-DAT comparator results

tests/AcDream.App.Tests/Physics/RetailCellArrayComparatorInstalledDatTests.cs registers each fixture through the real production dispatch (ShadowShapeBuilder.FromLandblockBspParts first, else the Setup CylSphere/Sphere fallback — LandblockPhysicsPublisher.PublishStaticEntity's exact branch order — plus the FromStaticRenderParts part array) against a registry backed by the real PhysicsDataCache, then reads back retail's CELLARRAY (TryGetRetailCellArray), today's COLLISION cells (GetOwnerCells), and today's OLD RENDER cells (ComputeStaticRenderCells over the identical part array and seed).

Fixture Route Parts Retail cells Collision cells Old-render cells Retail == old-render
1: Facility Hub stair Setup 0x02000623 (parent 0x8A02015F) BoundingBox 1 (single wide part, NOT multi-part — see below) 7: 0x8A02015F,0x8A0201C1,0x8A02015E,0x8A0201C0,0x8A0201C7,0x8A020141,0x8A020163 identical identical yes (exact set)
2: Cathedral ramp Setup 0x020009A2 (resolved parent 0xF4180112) BoundingBox 7 (0x01001FE8 + six 0x01001FE6 slabs) 3: 0xF4180112,0xF4180113,0xF4180009 identical identical yes (exact set)
3: #334 Neftet formation GfxObj 0x010046D8 (landblock 0x8764) BoundingBox 1 25 (5×5 rectangle about landcell (2,2), entirely within 0x8764) identical identical yes (exact set)
4: outdoor landblock-edge crosser, GfxObj 0x010046EB (landblock 0x8764, origin (133.38, 183.52)) BoundingBox 1 6: 0x87640030,0x8764002F,0x87650029,0x87640037,0x87640038,0x87650031 — 2 of 6 in neighbor landblock 0x8765 identical identical yes (exact set)
5: multi-part Setup, parts in different cells — see note

Every fixture reports retail == collision == old-render exactly (same ordered set), zero mismatches. This is a structural consequence of chunk 1a's route logic, not incidental: every one of these statics is BSP-bearing (hasPhysicsBsp=True), so RecomputeRetailCellArray's dispatch always takes the BoundingBox branch (CPhysicsObj::find_bbox_cell_list over the SAME part array, same seed, same PhysicsDataCache — the identical primitive ComputeStaticRenderCells already calls). A retail-vs-collision difference never appeared for these five fixtures either — the collision dispatch for a BSP-bearing object also flows through the box-walk over the same shapes, so there is no decorative-vs-collision part split to observe here (that divergence needs a fixture with a non-colliding decorative part alongside a colliding one — none of the fixtures required by the plan packet happen to be that shape; it remains open evidence for chunk 3, not a gap in this chunk's coverage).

Per-cell part-entry counts (GetRetailPartEntriesInCell) confirm retail's CELLARRAY-then-part-array insertion order: fixture 1's single part appears once in each of its 7 cells (7 entries total); fixture 2's 7 parts each appear in each of its 3 cells (21 entries total — every part registers in every cell of the entity's CELLARRAY, not a per-part cell subset, matching CPartArray::AddPartsShadow's exact semantics); fixtures 3 and 4's single part appears once per cell.

Fixture-1 correction against the plan packet's assumption: Facility Hub stair Setup 0x02000623 resolves via SetupMesh.Flatten to exactly ONE visual part, not several — its own wide vertex-array box spans all 7 crossed cells. It is a wide SINGLE-part static. The genuinely multi-part case (item 5, "parts in different cells") is fixture 2, the Cathedral ramp: 7 real parts, whose combined CELLARRAY spans 3 cells, with every part entered in every one of those 3 cells per retail's AddPartsShadow semantics — "parts in different cells" is best read as "the entity's CELLARRAY spans multiple cells," not "each part is confined to its own cell" (retail's render index has no such per-part cell subsetting; S3 owns per-part draw admission separately). No installed-DAT fixture with genuinely per-part-confined membership was found among the test files this chunk was pointed at.

Fixture-4 note: the first candidate a bounding-SPHERE-based edge scan picked was fixture 3's own Neftet formation GfxObj 0x010046D8 (69 m sphere radius crosses the landblock's 0 m edge at its 63.78 m origin) — but the outdoor flood's governing geometry is the vertex-array BOX (CLandCell::add_all_outside_cells divides the BOX by square_length, #334), and that object's real 96 m box (48 m half-width) does not reach the edge from its centered position. The scan was corrected to key on GfxObjPhysics.VisualBounds (the box) instead, and to explicitly exclude 0x010046D8 so fixture 4 stays independent of fixture 3; it now resolves to a different Neftet-landblock GfxObj (0x010046EB) genuinely near the block's Y=192 edge, producing 2 of 6 retail cells in the neighboring landblock 0x8765.

6. Chunk 3 evidence (2026-09-02)

Chunk 3 deletes the second flood chunk 1 left running beside the collision one. RegisterMultiPart/Register now compute the CELLARRAY exactly once via the shared ComputeContractACellArray primitive (Contract A: cylsphere route from the collision shapes' cylinders, else the bbox route over the part array) whenever a caller supplies a real part array — production always does, since chunk 1b threaded partArray: into every RegisterMultiPart call site. _entityToCells/_cells (collision, built from the actual collision shapes) and _retailCellArrays/_retailPartEntriesByCell (render, built from the part array) both read off that one array via PublishRetailCellArray. A caller with no part array (every existing unit test, and any future legacy caller) keeps the exact pre-chunk-3 dispatch — partArray ?? shapes degrades to the old hasBsp-branch flood over shapes alone — so no existing expectation for such a caller changed; the full Lane!=Timing&Lane!=Linux Core suite (4,957 tests before chunk 3, 4,961 after adding chunk-3 coverage) needed zero test-expectation edits.

Behavior change, exactly as predicted in §5: an object whose part array mixes a colliding BSP part with a decorative non-BSP-colliding visual part now gets a ShadowEntry collision row for its BSP part in EVERY cell the CELLARRAY reaches — including cells the BSP part does not geometrically occupy, because membership (which cells hold a row at all) is decided by the whole-array flood (Contract A), not per part. The BSP polygon test still decides actual contact at query time (TransitionTypes.FindObjCollisionsInCell never changed); only the candidate set widens. Objects whose part array is entirely BSP-bearing are UNCHANGED (chunk 1 evidence: the bbox route over the collision shapes and the bbox route over the identical part array agree exactly, so old-collision and new-collision compute the same set either way) — tests/AcDream.App.Tests/Physics/RetailCellArrayComparatorInstalledDatTests.cs still reports retail == collision for all five installed-DAT fixtures, now as a structural guarantee (both read the same array) rather than an incidental agreement. The synthetic pin for the mixed case is ShadowObjectRegistryRetailCellArrayTests.RegisterMultiPart_DecorativePartArrayEntry_WidensCollisionRowsToo.

b3b7d922 prune removal, re-verified: the bbox route (CellTransit.BuildShadowCellSetFromParts) still carries no post-flood prune of outside cells, matching Contract A's bbox branch exactly (§1 above: "the outdoor expansion... no later rule that removes the outside cells it just added"). Chunk 3 does not reintroduce one; the collision cut over inherits the same unpruned array the render side already used.

The staged pipeline (item B, the chunk-1 flagged gap): TryPrepareSetPosition built a scratch staging registry via InstallOwnerState, then ran the CommitSetPosition suffix on it — but InstallOwnerState never seeded _entityRetailPartArrays/_retailCellArrays/_retailCellArrayRoutes/ _retailPartEntriesByCell, so staging's own internal RecomputeRetailCellArrayIfPresent call (reached through RefreshPositionRows/ReplacePositionRows) found nothing retained and no-opped every time — the retail product silently never traveled through a live-entity SetPosition commit. PreparedShadowOwnerState now also carries RetailPartArray/RetailCellIds/RetailRoute/RetailRows; TryCaptureOwnerState captures them from the live registry, InstallOwnerState seeds them into staging, and a new PrepareRetailPartEntryReplacements (parallel to PrepareCellReplacements) diffs the staged retail rows against the live _retailPartEntriesByCell so TryApplySetPosition can publish them alongside the collision cell replacements it already applies. LandblockReplacementBuilder, MirrorOwnerFrom, and RefreshRetainedOwnerFrom all route through the same TryCaptureOwnerState/InstallOwnerState pair, so they inherit the fix for free without their own changes. Pinned by ShadowObjectRegistryRetailCellArrayTests.StagedSetPosition_WithRetainedPartArray_MovesBothProductsTogether and ..._KeepWhenEmpty_PreservesBothProductsTogether.

Scope decision — movement (RefreshPositionRows/ReplacePositionRows) is NOT unified with collision's cell list. Item A's title is "one flood per REGISTRATION"; ReplacePositionRows receives an externally-decided collision cell list from the Transition/physics engine (a different subsystem, not ComputeContractACellArray) and, unchanged since chunk 1, separately recomputes the retail product via RecomputeRetailCellArrayIfPresent on every move. This is not the "two floods disagreeing over the same input" problem chunk 3 targets (registration, where BOTH products used to flood the SAME shapes/partArray independently) — the transition-computed cross-cell list and Contract A's part-array flood are legitimately different computations over different inputs, and collapsing them into one would be an architecture change beyond this chunk's contract. Flagged here as an open question for the chunk 3 retail review, not resolved unilaterally.

Particles (item D): no production code path sets the PARTICLE_EMITTER_PS state bit (0x1000) when calling Register/RegisterMultiPart today, and particle emitters do not register with ShadowObjectRegistry at all — AcDream.App/Rendering/ParticleRenderer.cs owns their placement independently (_meshReferences.Register(emitter.Handle, gfxObjId), a GPU mesh-reference table, not a shadow-cell registration). There is no (state & 0x1000) != 0 branch to implement in this registry today; the retail add_particle_shadow_to_cell own-cell-only rule remains chunk 4 (or later) territory, to be implemented if/when particle emitters are ever routed through this registry.

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:

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.RecalculateUpdatePosition, 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 WalkProductionWorldDataRetailPViewRendererFrameRootComposition 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.