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>
36 KiB
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):
- 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_list0x00510fc0): seed the object's cell, then for every array cell callCEnvCell::find_transit_cells0x0052cae0, which per portal and per VISUAL part usesgfxobj->physics_sphereelsedrawing_spherefor the plane reject, the part bounding box (CPhysicsPart::GetBoundingBox) forPlane::intersect_boxandbox_intersects_cell, andadd_all_outside_cellsonce for an0xFFFFFFFFportal. Insertion order is retained; nothing prunes outside cells afterwards. - 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::AddPartsShadow0x00517e40, which adds EVERY part to that cell'sshadow_part_listwithplanes = num_shadow_objects > 1 ? cell->clip_planes : nulland 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). - Children receive the root's same CELLARRAY recursively in child-list
order. Particles (
state & 0x1000) register only in their own cell. - 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.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 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
CellTransitwalk 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 throughchildren). NowShadowObjectRegistry.AttachChildowns the inheritance at the registry, at attach time, and re-propagates on every root array change; the render-side walk is deleted. b3b7d922removed the indoor-seed stab-list prune inside the sharedBuildShadowCellSetFromParts, 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
WalkPViewstate, 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— buildsShadowShapeBuilder.FromStaticRenderParts(entity.MeshRefs, publication.StagingCache.GetGfxObj, publication.StagingCache.GetVisualBounds, out _)once per static and passes it aspartArray:to BOTH the BSP-branch and the Setup cylinder/sphere fallback-branchRegisterMultiPartcalls. The visual-bounds source ispublication.StagingCache, the SAMEPhysicsDataCacheWalkProductionWorldData.ResolveStaticRenderCellsalready reads (cache.GetGfxObj/cache.GetVisualBounds) — no new source was threaded, none was needed.LandblockPhysicsContentBuilder.PublishStaticCollision(the no-window/ Headless twin, driven fromHeadlessSessionWorldProjection) — same treatment, samecache.GetGfxObj/cache.GetVisualBoundspair.LiveEntityCollisionBuilder— addedShadowShapeBuilder.FromSetupRenderParts(Core, mirrorsFromStaticRenderParts's per-part dispatch but walks a Setup's parts directly instead ofMeshRefs, since a live entity has no meshRef list).LiveEntityCollisionRegistrationgained aRenderPartsfield;Build()computes it via two new resolvers (Func<uint, GfxObjPhysics?> _getGfxObj,Func<uint, GfxObjVisualBounds?> _getVisualBounds) that the public constructor wires toPhysicsDataCache.GetGfxObj/GetVisualBounds— the identical resolver SHAPEFromStaticRenderPartstakes, 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 (RenderPartsresolves to an empty list, enteringRecomputeRetailCellArray'spartArray.Count == 0early return — a no-op on every dictionary chunk 1a introduced).Register/ReconcileAppearancepasspartArray: registration.RenderPartsintoRegisterMultiPart/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 suppliespartArray:. - 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.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.ReplacePositionRowsnow calls the newPublishRetailProductFromExactCells(entityId, exactCells)— the SAMEexactCellslist collision just republished into — instead ofRecomputeRetailCellArrayIfPresent's independent Contract A flood.PublishRetailProductFromExactCellsis a thin wrapper over the existingPublishRetailCellArray, reusing the entity's LAST recorded dispatch route (registration never re-runs on a move, matching the pseudo-C: nocalc_cross_cells_staticcall anywhere inSetPositionInternal's non-full-recompute branch).ShadowObjectRegistry.RefreshPositionRows's "nothing retained at all" branch (both_entityToCellsand_suspendedEntityCellsempty) no longer calls anything — true keep-when-empty, matching the pseudo-C'selse(neither product touched).RecomputeRetailCellArrayIfPresentand the now-unreachableRecomputeRetailCellArrayare 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_RecomputesRetailCellArrayencoded exactly the two-source behavior this chunk removes (a.Nonecommit whose retail array independently flooded to the entity's NEW world position while collision stayed at the OLD retained cell). It is replaced byCommitSetPosition_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 everyCommitSetPositionvariant and the staged apply: retail array ==GetOwnerCellsafter 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.