feat(render): Campaign OVERHAUL S2 chunk 5 + closeout — registry is the only render membership owner

Chunk 5 (consumer cutover): WalkProductionWorldData's per-cell views are
borrowed from ShadowObjectRegistry.GetRetailPartEntriesInCell and resolved
through RenderSceneQuery.TryGetByLocalEntityId; every render-side sweep,
bucket, parent-cell and root-position fallback is deleted (AD-116 for the
one-frame registry→scene window, counted in UnregisteredRenderMembershipCount).
A live entity with visual parts but no collision geometry registers
render-only (LiveEntityCollisionBuilder computes the part array before the
empty-shapes gate).

Closeout fixes found while landing it:
- RefloodOwnerForLandblock forwards the retained part array — a reflood is
  retail's recalc_cross_cells over the SAME CPartArray; without it every owner
  touched by a landblock replacement commit lost its render membership.
- Non-colliding DAT statics register render-only from BOTH publishers
  (LandblockPhysicsPublisher.PublishStaticEntity,
  LandblockPhysicsContentBuilder.RegisterRenderOnlyStatic). The G2 self-gate
  pixel diff caught them vanishing (Facility Hub wall panels): retail floods
  every object regardless of collision (CEnvCell::init_static_objects
  0x0052c350, add_shadows_to_cells 0x00514ae0).
- S2 dual review fix batch (arch + retail lens, lead-verified):
  Suspend clears the retail product (remove_shadows_from_cells 0x00511230 is
  one transaction); AttachChild/DetachChild advance the mutation revision so
  a prepared SetPosition cannot clobber a child's rows; an attached child
  never floods on its own re-registration; RemoveLandblock and the non-rooted
  RetireOwnerFromLandblock prune retail rows (render-only statics end with
  their landblock); a render-only owner's no-cell-array commit republishes at
  its destination cell (AD-117); an empty non-null part array is treated as
  null; per-move closures/LINQ replaced by index loops; EnvCell shells stay
  out of the scene's LocalEntityId index (payload-less records); the index
  predicate compares the id; the dead per-cell scene indices are deleted.

Register: AD-116 (chunk 5), AD-117 (four residual Contract A/B readings).
Evidence: s2-membership-ownership-map.md §8 (chunk 5) and §9 (closeout).

Gates (Release): Core 4,984/4,984; Content 214/214; Runtime 1,884/1,884;
App hermetic lane 6,760/6,760; App InstalledDat lane 217 pass / 1 skip /
2 pre-existing #383 layout-fixture failures; App Windows lane 1/1.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-09-03 01:01:47 +02:00
parent f30039d3d9
commit c94a1a407e
20 changed files with 2141 additions and 1026 deletions

View file

@ -1103,11 +1103,25 @@ retail's synthetic outdoor cell; the player's current cell separately owns
sunlight and indoor lighting. A null root exists only for login/debug/streaming-
gap fallback frames. `RetailPViewRenderer` and `RetailPViewPassExecutor` are
composition/pass facades, and `PortalVisibilityBuilder` has no production
caller. This does **not** yet mean the complete world graph has one owner:
`WalkProductionWorldData` still reconstructs frame buckets from scene records,
AABBs, and aggregate shadow-owner cells. Campaign OVERHAUL OH3 replaces that
boundary with one canonical published cell graph and typed render/physics
membership transaction.
caller. **Campaign OVERHAUL S2 (chunks 1-5, closed 2026-09-03) gave render
and physics cross-cell membership one canonical owner:**
`ShadowObjectRegistry` computes retail's exact CELLARRAY
(`calc_cross_cells_static`/`add_shadows_to_cells`, Contract A/B) once per
registration/move transaction and retains it both as collision rows
(`GetOwnerCells`) and per-cell `RetailPartEntry` render rows
(`GetRetailPartEntriesInCell`) — one flood drives both, never two
independent ones. `WalkProductionWorldData` no longer reconstructs
membership from scene records, prepared visual AABBs, or per-record
aggregate shadow-owner cells: `GetCellStatics`/`GetCellDynamics`/
`GetOutdoorStatics`/`GetOutdoorDynamics` are a borrowed, on-demand view over
the registry's per-cell entries, resolved to a projected record through
`RenderSceneQuery.TryGetByLocalEntityId`. Portal topology, building
ownership, and drawable-cell identity remain a separate concern S2
deliberately left alone (S3's `WalkPView` state consolidates that
triplication, not this membership graph) — S2 scoped to render/physics
cross-cell membership only. Full detail:
`docs/architecture/worldbuilder-inventory.md`'s "Retail static render-shadow
cell membership" / "Consumer cutover to a borrowed per-cell view" entries.
**Current draw discipline and known non-retail order.** The walk records an
ordered event stream for landscape cells, building portals, EnvCell shells,

File diff suppressed because one or more lines are too long

View file

@ -193,19 +193,47 @@ per-entity retail CELLARRAY (`TryGetRetailCellArray`) plus per-cell part
entries (`GetRetailPartEntriesInCell`) — not two independent floods, and not
rebuilt per frame. A caller that supplies no part array (only test/legacy
call sites remain) keeps the pre-chunk-3 collision-only dispatch untouched.
`WalkProductionWorldData`'s indoor and outdoor static sweeps BORROW the
retail array whole per record; they no longer recompute membership from each
MeshRef's prepared visual AABB (the deleted `ResolveStaticRenderCells` /
`ShadowObjectRegistry.ComputeStaticRenderCells` pair). A structural
consequence: an object mixing a colliding BSP part with a decorative
non-BSP part now gets a collision row for its BSP part in every cell the
**Consumer cutover to a borrowed per-cell view (S2 chunk 5, 2026-09-03).**
`WalkProductionWorldData`'s three per-frame scene sweeps (indoor static,
outdoor static, dynamic) and their five per-cell bucket dictionaries are
deleted. `GetCellStatics`/`GetCellDynamics`/`GetOutdoorStatics`/
`GetOutdoorDynamics` instead read
`ShadowObjectRegistry.GetRetailPartEntriesInCell` for the QUERIED cell
directly — already in retail CELLARRAY-then-part-array insertion order —
collapse the entries to their distinct owning entity ids (an entity's own
run of entries for one cell is always contiguous, since the registry
removes-then-re-adds a whole entity's rows atomically), and resolve each id
back to its `RenderProjectionRecord` through
`RenderSceneQuery.TryGetByLocalEntityId` — a new presentation-side index
`ArchRenderScene` maintains alongside its existing per-cell/per-class
indices, keyed by the SAME `RenderSourceMetadata.LocalEntityId` the
registry is keyed by (App-only presentation state, never a second
membership source). Statics vs. dynamics is the record's own
`ProjectionClass` (`LiveDynamicRoot`/`EquippedChild` are dynamic; every
other class, including `ActiveAnimatedStatic`, is static — the same split
`ArchRenderScene`'s own internal indexing already used); building shells
are excluded from both — they draw at their own building's shell turn
(`_shellsByAnchor`, still filled by the ONE surviving narrow sweep, since
buildings are the landcell building channel, out of S2's scope), never at
the cell's ordinary object-list turn. A structural consequence carried
over from chunk 3: an object mixing a colliding BSP part with a decorative
non-BSP part gets a collision row for its BSP part in every cell the
combined CELLARRAY reaches, including cells the BSP part alone would not
have crossed — membership is decided by the whole-part-array flood, not per
part; the BSP polygon test still decides actual contact at query time. A
record whose registry entry has no retail array yet (a streaming-window race
between the static-projection journal and the physics publisher) falls back
to its authored parent cell alone, counted by
`WalkProductionWorldData.UnregisteredStaticRenderFallbackCount`.
have crossed — membership is decided by the whole-part-array flood, not
per part; the BSP polygon test still decides actual contact at query time.
An entity the registry HAS flooded into a cell but whose projected record
the presentation journal has not applied yet this frame (the transient
race between the physics publisher and the projection journal — AD-116)
contributes to NO cell for that frame; there is no fallback to an authored
parent cell or a root-position cell any more (both deleted this chunk),
matching retail's own rule that an object not yet in a cell is not drawn.
Every distinct entity id this happens for in one frame is counted once in
`WalkProductionWorldData.UnregisteredRenderMembershipCount` (renamed from
`UnregisteredStaticRenderFallbackCount`, which under the pre-chunk-5 model
also covered the separate dynamic-record fallback) and reported by one
print-only `[walk-membership]` line — gated on
`RenderingDiagnostics.ProbeFacilityStairsEnabled`, the walk family's
existing diagnostic flag — at the start of the next frame when nonzero.
A primitive-only Setup may reuse its already-authored cylsphere collision
cell set; BSP-bearing and pure-visual statics use the all-visual-part box
walk. The Facility Hub stair Setup `0x02000623` is the installed-DAT
@ -217,13 +245,15 @@ Moving multipart objects follow the sibling retail route rather than their
authored/feet cell alone: `CPhysicsObj::add_shadows_to_cells` installs every
`CPartArray` part in every cell of the object's retained `CELLARRAY`, and
`CPhysicsPart::Draw` stamps each part only after that part passes a cell's
portal test. `WalkProductionWorldData.ResolveDynamicRenderCells` therefore
reads `ShadowObjectRegistry.TryGetRetailCellArray` directly for every dynamic
record — the SAME borrow-not-recompute recipe the static sweeps already use
— falling back to `GetOwnerCells` (counted by the shared
`UnregisteredStaticRenderFallbackCount`) only when the registry has not
registered an array yet, while the walk classifier owns a separate
projection+part drawn-pass stamp. Retail advances that stamp after
portal test. `WalkProductionWorldData.GetCellDynamics`/`GetOutdoorDynamics`
therefore read the SAME per-cell `GetRetailPartEntriesInCell` borrowed view
the static getters use (S2 chunk 5), filtered to the dynamic
`ProjectionClass`es — not a per-record `TryGetRetailCellArray` lookup with
its own fallback any more; an entity the registry has flooded into a cell
but the presentation scene cannot resolve yet contributes to no cell there,
counted by the shared `UnregisteredRenderMembershipCount` (AD-116), while
the walk classifier owns a separate projection+part drawn-pass stamp.
Retail advances that stamp after
`LScape::draw` + `FlushAlphaList` and before the interior depth clear
(`PView::DrawCells` @0x005A4886), so a part may draw once in the landscape
pass and once again in the post-clear interior-cell pass; acdream re-arms the
@ -260,9 +290,11 @@ moment an attached projection's `WorldEntity` is registered against its
accepted parent; `CommitProjectionRemoval` (the withdrawal/unparent/teardown
funnel) calls `DetachChild`. A nested attachment (a child of a child)
resolves to the ultimate root by walking the existing attach chain at the
registry, bounded and cycle-safe — `WalkProductionWorldData` no longer walks
an accepted-parent chain of its own; it reads the child's own id straight off
`TryGetRetailCellArray`, identically to any other dynamic record.
registry, bounded and cycle-safe — `WalkProductionWorldData` never walks an
accepted-parent chain of its own; a child's part entries surface through the
SAME per-cell borrowed view (S2 chunk 5) every other dynamic record uses,
resolved to its own projection via `RenderSceneQuery.TryGetByLocalEntityId`,
identically to any other dynamic record.
**Slice I3 prepared collision extension (2026-07-25).** At its introduction,
the package remained format 1 and retained mesh type values 13; bake-tool 4

View file

@ -337,7 +337,7 @@ issue is closed, the strip was missed; delete both.
| `ACDREAM_PROBE_CATHEDRAL_SKIP_LOOKIN_SHELL` | Campaign FW exact look-in shell discriminator | full hexadecimal cell id, e.g. `F4180112` | suppresses one exact EnvCell shell only when reached through a building look-in; its ordinary interior-root repaint is unchanged | **Behavior-changing diagnostic**; never use as a production fix | `RenderingDiagnostics.ProbeCathedralSkipLookInShellCellId` |
| `ACDREAM_PROBE_CATHEDRAL_SKIP_SOUTH_BUILDING_SHELL` | Campaign FW south-building carrier discriminator | `=1` | suppresses only the own exterior shell of the cathedral building anchored at `0xF4180112`; its portal walk, look-in cells, and particles remain live | **Behavior-changing diagnostic**; never use as a production fix | `RenderingDiagnostics.ProbeCathedralSkipSouthBuildingShell` |
| `ACDREAM_PROBE_CATHEDRAL_SHELL_ORDER` | Campaign FW floating-stair shell-order trace | `=1` | samples the ONE walk's actual replay event order every fifth cathedral frame (`CLEAR`, `SEALS`, punches, marks, and EnvCell shells) and reports render-stamp-deduplicated whole-shell turns for cells `0xF4180104/0106/0107/0112/0113/0114` | print-only; never changes admission, depth state, or draw order | `RenderingDiagnostics.ProbeCathedralShellOrderEnabled` |
| `ACDREAM_PROBE_FACILITY_STAIRS` | #177 Facility Hub stair probe (committed by the `b3b7d922` investigation checkpoint) | `=1` | traces the classifier admission, ordered-stream append, ring upload, and final indirect draw of the authored stair GfxObj `0x010000DE` and the local-player setup parts in cells `0x8A02015E/015F/01C1`, plus the walk root signature for those cells | print-only; never influences admission, order, or depth state; scheduled for deletion in Campaign OVERHAUL S5 | `RenderingDiagnostics.ProbeFacilityStairsEnabled` (read sites in `RetailPViewRenderer`, `WalkPView`, `WalkProductionWorldData`, `WalkStaticStreamPopulator`, `WbDrawDispatcher.*`) |
| `ACDREAM_PROBE_FACILITY_STAIRS` | #177 Facility Hub stair probe (committed by the `b3b7d922` investigation checkpoint) | `=1` | traces the classifier admission, ordered-stream append, ring upload, and final indirect draw of the authored stair GfxObj `0x010000DE` and the local-player setup parts in cells `0x8A02015E/015F/01C1`, plus the walk root signature for those cells; Campaign OVERHAUL S2 chunk 5 added a `[walk-membership]` line, printed once at the start of a frame only when `WalkProductionWorldData.UnregisteredRenderMembershipCount` was nonzero the PRIOR frame (an entity the registry flooded into a cell but the presentation scene could not resolve yet that frame) | print-only; never influences admission, order, or depth state; scheduled for deletion in Campaign OVERHAUL S5 | `RenderingDiagnostics.ProbeFacilityStairsEnabled` (read sites in `RetailPViewRenderer`, `WalkPView`, `WalkProductionWorldData`, `WalkStaticStreamPopulator`, `WbDrawDispatcher.*`) |
| `ACDREAM_PROBE_SEAMDRAW` | #176, "throwaway apparatus" | `"1"`/`"true"`/blank → default #176 Facility Hub cell set (7 fixed hex ids); otherwise comma-separated hex cell-id list | change-deduped + 2 s-heartbeat `[seam-cell]`/`[seam-snap]`/`[seam-ent]`/`[seam-mask]` lines from `EnvCellRenderer.Render` and `WbDrawDispatcher` describing per-instance transforms and resolved light-set identities at target cells | print-only | `RenderingDiagnostics.ProbeSeamDrawEnabled` / `SeamDrawTargetCells` |
| `ACDREAM_PROBE_STEP_WALK` | a6.p3 issue #98 | `=1` | gates `[step-walk]` lines at select points in the transition sub-step loop and step-down probe (requested vs adjusted offset, sphere positions, contact planes, walkable flags) | print-only; no DebugPanel mirror | `PhysicsDiagnostics.ProbeStepWalkEnabled` |
| `ACDREAM_PROBE_SWEPT` | phase w stage 0 | `=1` | gates one `[cell-swept]` line per `ResolveWithTransition` call comparing the transition's swept cell vs the legacy static `ResolveCellId` path | print-only | `PhysicsDiagnostics.ProbeSweptEnabled` |

View file

@ -37,11 +37,11 @@ One transaction per object (`CPhysicsObj::calc_cross_cells_static`
| 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` |
| **END STATE (chunk 5, 2026-09-03).** `WalkProductionWorldData.GetCellStatics`/`GetCellDynamics`/`GetOutdoorStatics`/`GetOutdoorDynamics``ShadowObjectRegistry.GetRetailPartEntriesInCell(cellId)` → distinct entity ids in entry order → `RenderSceneQuery.TryGetByLocalEntityId` (`WalkProductionWorldData.cs` `ResolveCellView`; `RenderSceneContracts.cs`/`ArchRenderScene.cs` new `TryGetByLocalEntityId`/`_byLocalEntityId`). Superseded, in order: `ResolveStaticRenderCells`/`ComputeStaticRenderCells` (chunk 2 deleted these) → `ResolveIndoorStaticRenderCells`/`ResolveOutdoorStaticRenderCells`/`ResolveDynamicRenderCells` + the five per-cell bucket dictionaries + the three per-frame scene sweeps (chunk 5 deletes ALL of these) | none (borrowed, per QUERIED cell, on demand — no per-frame sweep of the scene at all any more) | one cell's retail `RetailPartEntry` rows, filtered to the record's own `ProjectionClass` (static vs. dynamic) and excluding `IsBuildingShell` | every `Get*` call at a walk turn (lazy, cached per cell per frame in the SAME `_cellCache`/`_cellDynamicCache`/`_outdoorMaterialized`/`_outdoorDynamicsMaterialized` dictionaries chunk 2 introduced) | `WalkFrameDriver.GetCellStatics`/`GetCellDynamics`/`GetOutdoorStatics`/`GetOutdoorDynamics`; `RetailPViewRenderer` particle owner union (`GetCellStatics`, unchanged call); unregistered fallback is now "contributes to no cell" (no authored-parent or root-position substitute), counted by the renamed `WalkProductionWorldData.UnregisteredRenderMembershipCount` (once per DISTINCT entity per frame, not per cell visit) — AD-116 |
| **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` |
| **DELETED (chunk 5).** Was `WalkProductionWorldData.ResolveDynamicRenderCells` (a per-record `TryGetRetailCellArray` read). Dynamics now share the SAME `ResolveCellView` end-state row above as statics — no record-keyed lookup, no separate dynamic resolver, no separate fallback | — | — | — | superseded by the END STATE row above |
| `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` |
@ -117,10 +117,11 @@ 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).
**Chunk 5 — consumer cutover and deletion (landed 2026-09-03).** 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). See §8 for the evidence.
Owner gate G2 after chunk 5 (plan §9 S2).
@ -479,3 +480,245 @@ nesting, and cycle rejection). App suite (hermetic lanes): 6,761/6,761 (was
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.
## 8. Chunk 5 evidence (2026-09-03)
**A — borrowed per-cell views.** `WalkProductionWorldData.BeginFrame`'s three
per-frame scene sweeps (indoor static, outdoor static, dynamic) and their
scratch arrays (`_indoorSweepScratch`, `_dynamicSweepScratch`), the five
per-cell bucket dictionaries (`_indoorByCell`, `_indoorDynamicsByCell`,
`_outdoorByCell`, `_outdoorDynamicsByCell`), `BucketIndoorRecord`/
`BucketOutdoorRecord`/`BucketDynamicRecord`, and
`ResolveIndoorStaticRenderCells`/`ResolveOutdoorStaticRenderCells`/
`ResolveDynamicRenderCells` are all deleted. `GetCellStatics`/
`GetCellDynamics`/`GetOutdoorStatics`/`GetOutdoorDynamics` share one private
resolver, `ResolveCellView(cellId, dynamic)`: read
`ShadowObjectRegistry.GetRetailPartEntriesInCell(cellId)` (already in retail
CELLARRAY-then-part-array insertion order), collapse ADJACENT entries with
the same `EntityId` to their distinct owning entity ids (safe because
`PublishRetailPartEntries` always removes-then-re-adds a whole entity's rows
atomically — one entity's own run for a cell is never interleaved with
another's), resolve each id to its `RenderProjectionRecord` via the new
`RenderSceneQuery.TryGetByLocalEntityId`, filter to the record's own
static-vs-dynamic `ProjectionClass` (`LiveDynamicRoot`/`EquippedChild` are
dynamic — the SAME split `ArchRenderScene.IsDynamic` already used
privately), and exclude `IsBuildingShell`. Indoor vs. outdoor needs no
dispatch of its own: the CALLER already queries the right id space (an
indoor `GetCellStatics(cellId)` call and an outdoor `GetOutdoorStatics(cellId)`
call each pass a cell id from their own id space), and the registry has
already published the entity into every crossed cell under its true id
either way. The per-frame result caches (`_cellCache`,
`_cellDynamicCache`, `_outdoorMaterialized`, `_outdoorDynamicsMaterialized`)
and the arena (`_arena`/`AppendToArena`) are UNCHANGED — chunk 5 adds one new
grow-on-demand scratch buffer, `_cellViewScratch` (starts at 64 records),
that `ResolveCellView` fills before a single `AppendToArena` call per cell,
matching the existing `_sweepScratch` growth shape. Building shells keep
their own narrow survivor sweep (`_shellsByAnchor`, `GetBuildingShellStatics`)
exactly as before — out of S2's scope (the landcell building channel).
The scene-side half of this borrow is new: `RenderSceneQuery` gains
`TryGetByLocalEntityId(uint localEntityId, out RenderProjectionRecord)`
(`RenderSceneContracts.cs`'s `IRenderSceneQuerySource` interface + the query
struct forwarder), implemented in `ArchRenderScene` by a new
`Dictionary<uint, RenderProjectionId> _byLocalEntityId` maintained at the
SAME `AddToIndices`/`RemoveFromIndices` insertion points as the existing
per-cell (`_cellStatics`/`_cellDynamics`) and per-class indices — purely
presentation-side derived state, cleared by `ClearIndices` alongside
everything else, never a second membership source. The one other
`IRenderSceneQuerySource` implementation in the tree, the
`DirectionalShadowCasterFrameTests.QuerySource` test fake, gained a matching
linear-scan implementation so the interface stays satisfiable outside
production.
**B — effect-object render-only registration.** `LiveEntityCollisionBuilder.
Build` (Runtime) now computes `renderParts` (`ShadowShapeBuilder.
FromSetupRenderParts`) BEFORE its early-return gate, and returns a non-null
`LiveEntityCollisionRegistration` whenever EITHER `shapes` (collision) or
`renderParts` is non-empty (or `retainEmptyPayload` is set) — previously an
empty `shapes` list alone (a Setup with visual parts but no CylSphere/
Sphere/physics-BSP part) returned `null` unconditionally, silently dropping
`renderParts` too and never reaching the registry at all. The materializer
call site (`DatLiveEntityProjectionMaterializer.cs:751-763`) needed NO
change: it already calls `LiveEntityCollisionBuilder.Register(_shadows,
collision)` whenever `Build` returns non-null, and `Register` already
forwards `registration.RenderParts` as `RegisterMultiPart`'s `partArray:`
argument.
`ShadowObjectRegistry.RegisterMultiPart`'s `shapes.Count == 0` branch no
longer unconditionally deregisters: when a non-empty `partArray` is also
supplied, it calls the new private `RegisterRenderOnly`, which floods via
`ComputeContractACellArray` with an EMPTY `collisionShapes` argument (so
Contract A's cylsphere test is always false and the bbox route always wins —
"route per Contract A with an empty collision dispatch"), publishes the
resulting CELLARRAY/`RetailPartEntry` rows exactly like any other
registration, and retains `_entityShapes[entityId] = []` (not absent) so
`UpdatePosition`'s `IsMultiPart && _entityShapes.TryGetValue` dispatch and
`ReplacePositionRows`'s per-shape loop both take the correct "multi-part,
zero shapes" branch on a later move instead of falling back to the
single-shape `Register` path or synthesizing a bogus zero-radius collision
entry. `ReplaceMultiPartPayload`'s own "not yet registered" early-return
guard gained the same `partArray`-aware condition, and `ReplacePositionRows`
gained a `wroteCollisionEntries` guard so a render-only entity moving
through the direct (non-staged) `CommitSetPosition` path never gets a ghost
`_entityToCells` entry with zero backing `ShadowEntry` rows. NO collision row
is EVER published for a render-only entity — `GetOwnerCells` stays empty
across register/move/deregister — matching Contract B exactly (an object
contributes to `shadow_object_list` only when it has a part TO shadow
there).
One adjacent gap was found and left OPEN, not fixed: `ShadowObjectRegistry.
RefloodOwnerForLandblock` (the STATIC/offline `LandblockPhysicsContentBuilder`
streaming-reflood path, `AcDream.Content`) calls `RegisterMultiPart`/
`Register` without forwarding the entity's retained `partArray` at all —
since `RegisterMultiPart`'s body always `DeregisterCore`s (which clears the
retail array) before re-flooding, ANY reflooded entity with a retained retail
part array silently loses its render product on reflood, and a genuinely
render-only entity would be deregistered outright (its `shapes.Count == 0`
with no forwarded `partArray` hits the `Deregister` branch). This predates
chunk 5 (it exists since chunk 1b/3 threaded `partArray:` through every OTHER
call site) and is out of chunk 5's live-entity-materializer scope — no
production live-entity path reaches `RefloodOwnerForLandblock` (only the
offline static content builder does) — but it is a real gap for
landblock-edge STATICS reflooded near a streaming boundary and should be
scoped as its own small follow-up.
**C — the one remaining fallback.** Under the borrowed-view model the
former TWO fallback directions collapse to ONE. "Scene has a record, registry
never registered it at all" is not observable any more — `ResolveCellView`
only ever iterates registry entries, so such an entity is simply never
visited (zero code, matching "an object not yet in a cell is not drawn"
exactly, no counter). "Registry HAS flooded the entity into a cell, but the
scene's projected record isn't resolvable yet" (the presentation journal
lagging the physics publisher within the same frame) is the one real,
observable race: `ResolveCellView`'s `!_scene.TryGetByLocalEntityId(...)`
branch counts the entity ONCE per frame (via a per-frame `HashSet<uint>
_unregisteredEntitiesThisFrame`, so an entity crossing several cells or
queried through both the static/dynamic getter pair is not double-counted)
into the renamed `WalkProductionWorldData.UnregisteredRenderMembershipCount`,
and contributes to NO cell — no authored-parent or root-position substitute
survives. `BeginFrame` prints one `[walk-membership] unregistered=N
tupleLandblock=0x…` line, gated on the existing walk-family flag
`RenderingDiagnostics.ProbeFacilityStairsEnabled` (reused, no new env var),
reporting the PRIOR frame's final count at the start of the next
`BeginFrame` — the only point a "frame is done" signal exists in this
class's lazy, on-demand `Get*` model. AD-116 files this deviation citing
AD-49's residency-race reasoning (the register's OTHER residency-timing row;
the ownership-map's original chunk-2 citation of "AD-40" was already stale
against the current register — AD-40 there is the unrelated fsf transient-bit
row — AD-49 is the one that actually argues residency-race safety).
**D — `GetOwnerCells` / `TryGetRetailCellArray` App-layer consumer audit.**
`grep -rn "\.GetOwnerCells(" src/AcDream.App/ src/AcDream.Runtime/
src/AcDream.Content/` after chunk 5 finds exactly ONE call site:
`WalkProductionWorldData.EmitFacilityShadowProbe` (unchanged this chunk) —
a diagnostic probe, gated on `ProbeFacilityStairsEnabled`, that PRINTS the
collision-owner cell set for the authored Facility/cathedral stair
SourceIds and the local player for comparison; it never feeds admission or
membership. `grep -rn "\.TryGetRetailCellArray(\|\.GetRetailPartEntriesInCell("
src/AcDream.App/` finds every call site inside `WalkProductionWorldData.cs`
itself (`ResolveCellView`, `StaticBucketContains`) — nothing else in App
reconstructs or reads registry membership. `RetailPViewRenderer`'s particle
owner union calls `GetCellStatics` (the borrowed view), unchanged.
**Test evidence.** Core `ShadowObjectRegistryRetailCellArrayTests`: 27/27
(was 22; +5 new fixtures covering item B's render-only registration,
keep-when-empty, deregister, and move). App `LiveEntityCollisionBuilderTests`:
13/13 (was 12; +1 covering `Build`/`Register` producing a render-only
registration end to end against a real registry). App
`WalkProductionWorldDataTests`: 11/11 (was 12 pre-chunk-5 — 8 bucket-primitive
tests and 2 `ResolveDynamicRenderCells` tests deleted with their methods, 9
new tests added against the real production path — a real bare
`ShadowObjectRegistry` + real `ArchRenderScene`, never a hand-fed bucket
dictionary — covering registry-driven resolution, the one remaining
fallback + its per-entity-not-per-cell counting, static/dynamic split,
building-shell exclusion, outdoor parity, per-frame caching, and
`StaticBucketContains`). Net App suite delta: 0 (6,761/6,761 both before and
after, since the file-level and materializer-level deltas cancel exactly).
`Issue177StairDescentCameraFloodTests.FacilityStairAssembly_
RegisterAcross015FTo015E` (Lane=InstalledDat) required only the
`UnregisteredStaticRenderFallbackCount``UnregisteredRenderMembershipCount`
rename — its own registry/scene wiring already matches the chunk-5 model
byte-for-byte since it looks records up by `LocalEntityId`, not by any
formulaic id.
**Automated gate.** Full Release solution build: 0 warnings, 0 errors. Core
`Lane!=Timing&Lane!=Linux`: 4,975/4,975 (4,970 + 5 new). App hermetic lanes:
6,761/6,761 (unchanged net). App `Lane=InstalledDat`: 147 passed / 97 skipped
/ 1 failed — the one failure,
`TowerAscentReplayTests.TowerAscent_StaircaseStaysConeVisible_EveryStep`,
is PRE-EXISTING and unrelated: the test file has no reference to
`WalkProductionWorldData` or `ShadowObjectRegistry` at all, its last commit
predates Campaign OVERHAUL entirely, and `docs/ISSUES.md`'s #119-residual
entry already documents this exact pin as needing re-validation against the
production resolver before it can be trusted ("its band came from the
harness's AABB root model, not the production sweep") — an S3-scope
viewcone/cone-cull concern, not S2 membership. Runtime
`Lane!=Timing&Lane!=Linux`: 1,884/1,884 (unchanged — chunk 5 touched no
Runtime test-visible behavior beyond `LiveEntityCollisionBuilder.Build`'s
early-return condition, already covered from the App side).
**Allocation note.** `ResolveCellView` performs no per-call heap allocation
after warmup: `GetRetailPartEntriesInCell` returns an existing `List<T>`
cast to `IReadOnlyList<T>` (no copy), the entity-id dedup loop is a plain
`for` over that list with two `uint` locals, `_cellViewScratch` is a
grow-on-demand array reused every call (doubles only when a cell's
membership exceeds its current capacity), and `AppendToArena` copies into
the existing per-frame arena (`_arena`), also grow-on-demand. The one
per-frame allocation-shaped addition is `_unregisteredEntitiesThisFrame`
(a `HashSet<uint>`, cleared not reallocated every `BeginFrame`, same
grow-once-then-stable shape as every other per-frame dictionary/set in this
class) — its `.Add(uint)` calls are non-boxing value-type operations.
## 9. S2 closeout: the reflood gap, the G2 self-gate finding, and the review fix round (2026-09-03)
**Reflood (lead, landing chunk 5).** `RefloodOwnerForLandblock` re-registered
from `_entityShapes` only, so every owner reflooded by a landblock replacement
commit (`PhysicsEngine.ApplyCommittedOwnerReplacement`) or by the Content
builder's post-publication reflood lost its retail product — with chunk 5's
consumer reading nothing else, it vanished. Fixed by forwarding the retained
`_entityRetailPartArrays` entry exactly as `UpdatePosition` already did
(retail: a reflood is `recalc_cross_cells` over the SAME `CPartArray`). Pins:
`RefloodLandblock_KeepsTheRetailProductOfACollisionOwnerWithAPartArray`,
`RefloodLandblock_KeepsARenderOnlyOwner` (both fail without the fix).
**G2 self-gate finding (pixel diff, not eyes).** Frame `02-facility-stairs.png`
differed from the chunk-4 run in 23% of pixels; the chunk-4/baseline frames
show the wall panels beside the corridor lit by its purple lights (left-wall
mean RGB 65,17,21 baseline / 78,20,25 chunk 4) while the chunk-5 frame shows
plain grey stone (29,26,23) with the drudge still pink-lit. Not lighting: the
panels are DAT statics with no collision geometry, and `PublishStaticEntity`
registered a static only under `entityBspCount > 0` / `setupShapes.Count > 0`
— they had only ever been drawn through the parent-cell fallback chunk 5
deleted. `UnregisteredRenderMembershipCount` stayed 0 because it counts the
registry→scene direction only. Retail floods every object regardless of
collision (`CEnvCell::init_static_objects` 0x0052c350 → `add_obj_to_cell`;
`add_shadows_to_cells` 0x00514ae0 adds the shadow row unconditionally and
gates only `AddPartsShadow` on `part_array != 0`). Fix: both publishers
register a non-colliding static render-only (`RegisterMultiPart` with empty
shapes + the part array → `RegisterRenderOnly`). Pins:
`CompletePublication_NonCollidingStaticRegistersRenderOnly` (App),
`PublishStaticCollision_NonCollidingGfxObjStatic_RegistersRenderOnly`
(Content).
**Review fix round (arch lens + retail lens, both Opus, every finding
lead-verified against the source before any edit).**
| # | Finding (verified) | Fix | Pin |
|---|---|---|---|
| A1/R2 | `Suspend` removed collision rows only; a suspended remote kept render membership at its last cells | `Suspend` clears the retail rows + republishes children; part array/route retained; the un-suspending `.None` commit republishes from the retained cells | `Suspend_ClearsTheRetailProduct_AndTheUnsuspendingMoveRepublishesIt` |
| A2 | `AttachChild`/`DetachChild` never advanced `_mutationRevision`, so a prepared SetPosition's wholesale cell-list install could drop an attached child's rows | both advance the revision | `AttachChild_AndDetachChild_AdvanceTheMutationRevision` |
| A3 | a re-registration of an attached child (appearance update) flooded it independently, overriding the inherited root array | `PublishRetailCellArray` short-circuits for an attached child: refresh `_childPartArrays`, `PublishChildEntries` | `RegisterMultiPart_OfAnAttachedChild_KeepsInheritingTheRootsCells` |
| A4/R4 | `RemoveLandblock` and the non-rooted `RetireOwnerFromLandblock` branch left every retail row behind (unbounded growth, stale membership); render-only statics were unreachable by the static-retirement loop | `RemoveRetailProductForPrefix` (rows for the prefix's cells, per-owner cell pruning, static/render-only owners end with the landblock); the retire branch prunes retail cells first and handles render-only owners | `RemoveLandblock_ClearsTheRetailProduct_IncludingRenderOnlyStatics`, `RetireOwnerFromLandblock_PrunesTheRetailRowsOfANonRootedOwner` |
| R3 | `RefreshPositionRows` (`.None`/keep-when-empty) republished from collision cells only → a render-only owner could never move | terminal branch republishes a render-only owner at its destination seed cell (AD-117 item 1) | `CommitSetPosition_None_MovesARenderOnlyOwnerToItsDestinationCell` |
| A9 | `ReplaceMultiPartPayload` with a non-null EMPTY part array left a cell array with no rows and a permanent no-op on later moves | `partArray is { Count: > 0 }` | `ReplaceMultiPartPayload_WithAnEmptyPartArray_KeepsThePriorRows` |
| A7/A8 | `RemoveAll` closure per cell per move; LINQ `Where().ToArray()` in the staged capture | `RemoveOwnerPartRows` + `CollectOwnerRows`/`CollectOwnerPartRows` | existing suites |
| A5 | EnvCell shell projections carry their CELL id as `LocalEntityId`; dungeon cell ids alias the `0x8…` scenery namespace in `_byLocalEntityId` | shells are not indexed | `TryGetByLocalEntityId_IgnoresEnvCellShellRecords` |
| A6 | `IndexMembershipEquals` omitted `LocalEntityId`, so an in-place id rebind left the index stale | compares the id | `Update_ThatRebindsTheLocalEntityId_MovesTheIndex` |
| A10 | `_cellStatics`/`_cellDynamics` + `GetCellCount`/`CopyCellTo`/`GetCellStaticCount`/`CopyCell*To` had zero production callers | deleted | `ArchRenderSceneTests` updated |
| A11/A12, R5R7 | live list handed out; probe-flag coupling; unloaded-neighbour rows; circumsphere cheap reject; particle branch | documented (AD-117 items 24; S5 tail) | — |
Verified-correct list from both reviews (kept for S5): Contract A dispatch,
`find_bbox_cell_list` shape, `AddPartsShadow` order/clip-plane rule,
movement from the transition's array, keep-when-empty, child inheritance
order, `Deregister` symmetry, cross-generation adoption seeding the part
array, ordering determinism, zero-alloc `ResolveCellView`, layering.