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- sunlight and indoor lighting. A null root exists only for login/debug/streaming-
gap fallback frames. `RetailPViewRenderer` and `RetailPViewPassExecutor` are gap fallback frames. `RetailPViewRenderer` and `RetailPViewPassExecutor` are
composition/pass facades, and `PortalVisibilityBuilder` has no production composition/pass facades, and `PortalVisibilityBuilder` has no production
caller. This does **not** yet mean the complete world graph has one owner: caller. **Campaign OVERHAUL S2 (chunks 1-5, closed 2026-09-03) gave render
`WalkProductionWorldData` still reconstructs frame buckets from scene records, and physics cross-cell membership one canonical owner:**
AABBs, and aggregate shadow-owner cells. Campaign OVERHAUL OH3 replaces that `ShadowObjectRegistry` computes retail's exact CELLARRAY
boundary with one canonical published cell graph and typed render/physics (`calc_cross_cells_static`/`add_shadows_to_cells`, Contract A/B) once per
membership transaction. 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 **Current draw discipline and known non-retail order.** The walk records an
ordered event stream for landscape cells, building portals, EnvCell shells, 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 entries (`GetRetailPartEntriesInCell`) — not two independent floods, and not
rebuilt per frame. A caller that supplies no part array (only test/legacy 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. call sites remain) keeps the pre-chunk-3 collision-only dispatch untouched.
`WalkProductionWorldData`'s indoor and outdoor static sweeps BORROW the **Consumer cutover to a borrowed per-cell view (S2 chunk 5, 2026-09-03).**
retail array whole per record; they no longer recompute membership from each `WalkProductionWorldData`'s three per-frame scene sweeps (indoor static,
MeshRef's prepared visual AABB (the deleted `ResolveStaticRenderCells` / outdoor static, dynamic) and their five per-cell bucket dictionaries are
`ShadowObjectRegistry.ComputeStaticRenderCells` pair). A structural deleted. `GetCellStatics`/`GetCellDynamics`/`GetOutdoorStatics`/
consequence: an object mixing a colliding BSP part with a decorative `GetOutdoorDynamics` instead read
non-BSP part now gets a collision row for its BSP part in every cell the `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 combined CELLARRAY reaches, including cells the BSP part alone would not
have crossed — membership is decided by the whole-part-array flood, not per have crossed — membership is decided by the whole-part-array flood, not
part; the BSP polygon test still decides actual contact at query time. A per part; the BSP polygon test still decides actual contact at query time.
record whose registry entry has no retail array yet (a streaming-window race An entity the registry HAS flooded into a cell but whose projected record
between the static-projection journal and the physics publisher) falls back the presentation journal has not applied yet this frame (the transient
to its authored parent cell alone, counted by race between the physics publisher and the projection journal — AD-116)
`WalkProductionWorldData.UnregisteredStaticRenderFallbackCount`. 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 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 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 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 authored/feet cell alone: `CPhysicsObj::add_shadows_to_cells` installs every
`CPartArray` part in every cell of the object's retained `CELLARRAY`, and `CPartArray` part in every cell of the object's retained `CELLARRAY`, and
`CPhysicsPart::Draw` stamps each part only after that part passes a cell's `CPhysicsPart::Draw` stamps each part only after that part passes a cell's
portal test. `WalkProductionWorldData.ResolveDynamicRenderCells` therefore portal test. `WalkProductionWorldData.GetCellDynamics`/`GetOutdoorDynamics`
reads `ShadowObjectRegistry.TryGetRetailCellArray` directly for every dynamic therefore read the SAME per-cell `GetRetailPartEntriesInCell` borrowed view
record — the SAME borrow-not-recompute recipe the static sweeps already use the static getters use (S2 chunk 5), filtered to the dynamic
— falling back to `GetOwnerCells` (counted by the shared `ProjectionClass`es — not a per-record `TryGetRetailCellArray` lookup with
`UnregisteredStaticRenderFallbackCount`) only when the registry has not its own fallback any more; an entity the registry has flooded into a cell
registered an array yet, while the walk classifier owns a separate but the presentation scene cannot resolve yet contributes to no cell there,
projection+part drawn-pass stamp. Retail advances that stamp after 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 `LScape::draw` + `FlushAlphaList` and before the interior depth clear
(`PView::DrawCells` @0x005A4886), so a part may draw once in the landscape (`PView::DrawCells` @0x005A4886), so a part may draw once in the landscape
pass and once again in the post-clear interior-cell pass; acdream re-arms the pass and once again in the post-clear interior-cell pass; acdream re-arms the
@ -260,9 +290,11 @@ moment an attached projection's `WorldEntity` is registered against its
accepted parent; `CommitProjectionRemoval` (the withdrawal/unparent/teardown accepted parent; `CommitProjectionRemoval` (the withdrawal/unparent/teardown
funnel) calls `DetachChild`. A nested attachment (a child of a child) funnel) calls `DetachChild`. A nested attachment (a child of a child)
resolves to the ultimate root by walking the existing attach chain at the resolves to the ultimate root by walking the existing attach chain at the
registry, bounded and cycle-safe — `WalkProductionWorldData` no longer walks registry, bounded and cycle-safe — `WalkProductionWorldData` never walks an
an accepted-parent chain of its own; it reads the child's own id straight off accepted-parent chain of its own; a child's part entries surface through the
`TryGetRetailCellArray`, identically to any other dynamic record. 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, **Slice I3 prepared collision extension (2026-07-25).** At its introduction,
the package remained format 1 and retained mesh type values 13; bake-tool 4 the package remained format 1 and retained mesh type values 13; bake-tool 4

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_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_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_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_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_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` | | `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 | | 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) | | **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).** `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).** 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) | | **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) | | `LightManager.SelectForCell` (`LightManager.cs:594-650`) | flat `_all` list, sphere overlap | per-cell light subset | per cell drawn | lighting (S5 owns; retail selects from the current EnvCell + stab list) |
| `DirectionalShadowCasterFrame` (`DirectionalShadowCasterFrame.cs:229-233`) | flat outdoor indices, no cell filter | caster list | per frame | shadow pass (S5) | | `DirectionalShadowCasterFrame` (`DirectionalShadowCasterFrame.cs:229-233`) | flat outdoor indices, no cell filter | caster list | per frame | shadow pass (S5) |
| Topology: `EnvCellLandblockBuildBuilder.BuildVisibilityCell` vs `WalkCellFactory.FromParsed` vs `FlatEnvCellTopology` | same DAT / pak | three distinct portal graphs (`LoadedCell`, `WalkCell`, flat asset) | per landblock build | render, walk, `CellTransit` | | Topology: `EnvCellLandblockBuildBuilder.BuildVisibilityCell` vs `WalkCellFactory.FromParsed` vs `FlatEnvCellTopology` | same DAT / pak | three distinct portal graphs (`LoadedCell`, `WalkCell`, flat asset) | per landblock build | render, walk, `CellTransit` |
@ -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 registry — chunk 3's finding stands, deferred); the render-side parent-chain
walk is deleted. See §7. walk is deleted. See §7.
**Chunk 5 — consumer cutover and deletion.** Remaining `WalkProductionWorldData` **Chunk 5 — consumer cutover and deletion (landed 2026-09-03).** Remaining
buckets become borrowed views; particle owner-cell union reads the same; `WalkProductionWorldData` buckets become borrowed views; particle owner-cell
duplicate dictionaries and fallbacks are deleted; architecture text updated. union reads the same; duplicate dictionaries and fallbacks are deleted;
Lights and directional shadows stay as they are (S5). 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). 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: 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 1,884/1,884 (unchanged — chunk 4 touched no Runtime source). Full solution
Release build: 0 warnings, 0 errors. 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.

View file

@ -31,10 +31,11 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource
private readonly HashSet<RenderProjectionId> _selectable = []; private readonly HashSet<RenderProjectionId> _selectable = [];
private readonly HashSet<RenderProjectionId> _lightCandidates = []; private readonly HashSet<RenderProjectionId> _lightCandidates = [];
private readonly HashSet<RenderProjectionId> _dirty = []; private readonly HashSet<RenderProjectionId> _dirty = [];
private readonly Dictionary<uint, HashSet<RenderProjectionId>> // Campaign OVERHAUL S2 chunk 5: presentation-side lookup by the entity's
_cellStatics = []; // stable LocalEntityId — see IRenderSceneQuerySource.TryGetByLocalEntityId.
private readonly Dictionary<uint, HashSet<RenderProjectionId>> // Maintained at the same register/update/unregister points as the cell
_cellDynamics = []; // indices above; never a membership source.
private readonly Dictionary<uint, RenderProjectionId> _byLocalEntityId = [];
private ArchWorld _world; private ArchWorld _world;
private RenderProjectionCounts _counts; private RenderProjectionCounts _counts;
private ulong _lastAppliedJournalSequence; private ulong _lastAppliedJournalSequence;
@ -434,6 +435,23 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource
return false; return false;
} }
bool IRenderSceneQuerySource.TryGetByLocalEntityId(
RenderSceneGeneration generation,
uint localEntityId,
out RenderProjectionRecord record)
{
EnsureQueryGeneration(generation);
if (_byLocalEntityId.TryGetValue(localEntityId, out RenderProjectionId id)
&& _entries.TryGetValue(id, out SceneEntry entry))
{
record = ReadRecord(in entry);
return true;
}
record = default;
return false;
}
int IRenderSceneQuerySource.CopyById( int IRenderSceneQuerySource.CopyById(
RenderSceneGeneration generation, RenderSceneGeneration generation,
ReadOnlySpan<RenderProjectionId> ids, ReadOnlySpan<RenderProjectionId> ids,
@ -499,31 +517,6 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource
return CopyIdsTo(source, destination); return CopyIdsTo(source, destination);
} }
int IRenderSceneQuerySource.GetCellCount(
RenderSceneGeneration generation,
uint fullCellId,
bool dynamic)
{
EnsureQueryGeneration(generation);
Dictionary<uint, HashSet<RenderProjectionId>> index =
dynamic ? _cellDynamics : _cellStatics;
return index.TryGetValue(fullCellId, out var ids) ? ids.Count : 0;
}
int IRenderSceneQuerySource.CopyCellTo(
RenderSceneGeneration generation,
uint fullCellId,
bool dynamic,
Span<RenderProjectionRecord> destination)
{
EnsureQueryGeneration(generation);
Dictionary<uint, HashSet<RenderProjectionId>> index =
dynamic ? _cellDynamics : _cellStatics;
return index.TryGetValue(fullCellId, out var ids)
? CopyIdsTo(ids, destination)
: 0;
}
private static ArchWorld CreateWorld() => private static ArchWorld CreateWorld() =>
ArchWorld.Create( ArchWorld.Create(
archetypeCapacity: InitialArchetypeCapacity, archetypeCapacity: InitialArchetypeCapacity,
@ -809,21 +802,14 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource
if (staticProjection) if (staticProjection)
{ {
if (indoor) if (indoor)
{
_indoorCellStatics.Add(record.Id); _indoorCellStatics.Add(record.Id);
AddCell(_cellStatics, record.Residency.FullCellId, record.Id);
}
else else
{
_outdoorStatics.Add(record.Id); _outdoorStatics.Add(record.Id);
} }
}
else else
{ {
_dynamics.Add(record.Id); _dynamics.Add(record.Id);
if (indoor) if (!indoor)
AddCell(_cellDynamics, record.Residency.FullCellId, record.Id);
else
_outdoorDynamics.Add(record.Id); _outdoorDynamics.Add(record.Id);
if ((record.Flags & RenderProjectionFlags.PortalStraddling) != 0) if ((record.Flags & RenderProjectionFlags.PortalStraddling) != 0)
_portalStraddlingDynamics.Add(record.Id); _portalStraddlingDynamics.Add(record.Id);
@ -837,6 +823,17 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource
_lightCandidates.Add(record.Id); _lightCandidates.Add(record.Id);
if (record.DirtyMask != RenderDirtyMask.None) if (record.DirtyMask != RenderDirtyMask.None)
_dirty.Add(record.Id); _dirty.Add(record.Id);
// An EnvCell shell projection carries its CELL id in the entity-id
// slot (StaticRenderProjectionJournal.ProjectEnvCellShell); cell ids
// with a 0x8_ high nibble alias the procedural-scenery entity
// namespace, so shells never enter this index (S2 review F5) — the
// walk reaches them through the leaf-renderer shell path, never by
// entity id. A shell is the one record built WITHOUT an entity
// payload (every static/dynamic entity record carries its MeshRefs
// from RenderProjectionRecordFactory); the projection class cannot
// tell them apart because interior statics share IndoorCellStatic.
if (record.Source.LocalEntityId != 0 && !IsEnvCellShell(in record))
_byLocalEntityId[record.Source.LocalEntityId] = record.Id;
AdvanceIndexRevision(); AdvanceIndexRevision();
AdvanceDirectionalShadowTopologyRevision(); AdvanceDirectionalShadowTopologyRevision();
} }
@ -852,12 +849,24 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource
_selectable.Remove(record.Id); _selectable.Remove(record.Id);
_lightCandidates.Remove(record.Id); _lightCandidates.Remove(record.Id);
_dirty.Remove(record.Id); _dirty.Remove(record.Id);
RemoveCell(_cellStatics, record.Residency.FullCellId, record.Id); if (record.Source.LocalEntityId != 0
RemoveCell(_cellDynamics, record.Residency.FullCellId, record.Id); && _byLocalEntityId.TryGetValue(
record.Source.LocalEntityId,
out RenderProjectionId mapped)
&& mapped == record.Id)
{
_byLocalEntityId.Remove(record.Source.LocalEntityId);
}
AdvanceIndexRevision(); AdvanceIndexRevision();
AdvanceDirectionalShadowTopologyRevision(); AdvanceDirectionalShadowTopologyRevision();
} }
/// <summary>The record is an EnvCell shell (cell geometry), not an
/// entity: no entity payload was ever attached. See AddToIndices.</summary>
private static bool IsEnvCellShell(in RenderProjectionRecord record) =>
record.ProjectionClass == RenderProjectionClass.IndoorCellStatic
&& record.EntityPayload.MeshRefs is null;
private static bool IndexMembershipEquals( private static bool IndexMembershipEquals(
in RenderProjectionRecord left, in RenderProjectionRecord left,
in RenderProjectionRecord right) in RenderProjectionRecord right)
@ -870,6 +879,8 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource
| RenderProjectionFlags.SpatiallyResident; | RenderProjectionFlags.SpatiallyResident;
return left.ProjectionClass == right.ProjectionClass return left.ProjectionClass == right.ProjectionClass
// _byLocalEntityId is keyed by this field (S2 review F6).
&& left.Source.LocalEntityId == right.Source.LocalEntityId
&& left.Source.ParentCellId == right.Source.ParentCellId && left.Source.ParentCellId == right.Source.ParentCellId
&& left.Residency.FullCellId == right.Residency.FullCellId && left.Residency.FullCellId == right.Residency.FullCellId
&& (left.Flags & indexedFlags) == (right.Flags & indexedFlags) && (left.Flags & indexedFlags) == (right.Flags & indexedFlags)
@ -1141,33 +1152,6 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource
projectionClass is RenderProjectionClass.LiveDynamicRoot projectionClass is RenderProjectionClass.LiveDynamicRoot
or RenderProjectionClass.EquippedChild; or RenderProjectionClass.EquippedChild;
private static void AddCell(
Dictionary<uint, HashSet<RenderProjectionId>> index,
uint fullCellId,
RenderProjectionId id)
{
if (fullCellId == 0)
return;
if (!index.TryGetValue(fullCellId, out HashSet<RenderProjectionId>? ids))
{
ids = [];
index.Add(fullCellId, ids);
}
ids.Add(id);
}
private static void RemoveCell(
Dictionary<uint, HashSet<RenderProjectionId>> index,
uint fullCellId,
RenderProjectionId id)
{
if (!index.TryGetValue(fullCellId, out HashSet<RenderProjectionId>? ids))
return;
ids.Remove(id);
if (ids.Count == 0)
index.Remove(fullCellId);
}
private HashSet<RenderProjectionId> Index(RenderSceneIndex index) => private HashSet<RenderProjectionId> Index(RenderSceneIndex index) =>
index switch index switch
{ {
@ -1223,13 +1207,7 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource
+ _selectable.EnsureCapacity(0) + _selectable.EnsureCapacity(0)
+ _lightCandidates.EnsureCapacity(0) + _lightCandidates.EnsureCapacity(0)
+ _dirty.EnsureCapacity(0); + _dirty.EnsureCapacity(0);
foreach (HashSet<RenderProjectionId> ids in _cellStatics.Values) long cellLookupSlots = _byLocalEntityId.EnsureCapacity(0);
slots += ids.EnsureCapacity(0);
foreach (HashSet<RenderProjectionId> ids in _cellDynamics.Values)
slots += ids.EnsureCapacity(0);
long cellLookupSlots =
_cellStatics.EnsureCapacity(0)
+ _cellDynamics.EnsureCapacity(0);
return checked(slots * 24L + cellLookupSlots * 40L); return checked(slots * 24L + cellLookupSlots * 40L);
} }
@ -1244,8 +1222,7 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource
_selectable.Clear(); _selectable.Clear();
_lightCandidates.Clear(); _lightCandidates.Clear();
_dirty.Clear(); _dirty.Clear();
_cellStatics.Clear(); _byLocalEntityId.Clear();
_cellDynamics.Clear();
} }
private void IncrementCount(RenderProjectionClass projectionClass) private void IncrementCount(RenderProjectionClass projectionClass)

View file

@ -557,6 +557,22 @@ internal interface IRenderSceneQuerySource
RenderProjectionId id, RenderProjectionId id,
out RenderProjectionRecord record); out RenderProjectionRecord record);
/// <summary>
/// Campaign OVERHAUL S2 chunk 5: a presentation-side lookup by the
/// entity's stable <see cref="RenderSourceMetadata.LocalEntityId"/> —
/// the SAME id <see cref="ShadowObjectRegistry"/>'s retail CELLARRAY and
/// per-cell <c>RetailPartEntry</c> rows are keyed by. This is derived
/// presentation state (maintained alongside the existing per-cell/
/// per-class indices at the same register/update/unregister points),
/// never a second membership source: cell membership still comes from
/// the registry alone, this only resolves an entity id back to its
/// current projected record.
/// </summary>
bool TryGetByLocalEntityId(
RenderSceneGeneration generation,
uint localEntityId,
out RenderProjectionRecord record);
int CopyById( int CopyById(
RenderSceneGeneration generation, RenderSceneGeneration generation,
ReadOnlySpan<RenderProjectionId> ids, ReadOnlySpan<RenderProjectionId> ids,
@ -572,16 +588,10 @@ internal interface IRenderSceneQuerySource
RenderSceneIndex index, RenderSceneIndex index,
Span<RenderProjectionRecord> destination); Span<RenderProjectionRecord> destination);
int GetCellCount( // Campaign OVERHAUL S2 review fix (arch F10): the per-cell scene indices
RenderSceneGeneration generation, // (GetCellCount/CopyCellTo, keyed by the single authored FullCellId) are
uint fullCellId, // deleted — the registry's retail CELLARRAY is the ONLY render-membership
bool dynamic); // source (ShadowObjectRegistry.GetRetailPartEntriesInCell).
int CopyCellTo(
RenderSceneGeneration generation,
uint fullCellId,
bool dynamic,
Span<RenderProjectionRecord> destination);
} }
internal readonly struct RenderSceneQuery internal readonly struct RenderSceneQuery
@ -626,6 +636,11 @@ internal readonly struct RenderSceneQuery
out RenderProjectionRecord record) => out RenderProjectionRecord record) =>
Source.TryGet(Generation, id, out record); Source.TryGet(Generation, id, out record);
public bool TryGetByLocalEntityId(
uint localEntityId,
out RenderProjectionRecord record) =>
Source.TryGetByLocalEntityId(Generation, localEntityId, out record);
public int CopyById( public int CopyById(
ReadOnlySpan<RenderProjectionId> ids, ReadOnlySpan<RenderProjectionId> ids,
Span<RenderProjectionRecord> destination) => Span<RenderProjectionRecord> destination) =>
@ -644,30 +659,6 @@ internal readonly struct RenderSceneQuery
Span<RenderProjectionRecord> destination) => Span<RenderProjectionRecord> destination) =>
Source.CopyIndexTo(Generation, index, destination); Source.CopyIndexTo(Generation, index, destination);
public int GetCellStaticCount(uint fullCellId) =>
Source.GetCellCount(Generation, fullCellId, dynamic: false);
public int CopyCellStaticsTo(
uint fullCellId,
Span<RenderProjectionRecord> destination) =>
Source.CopyCellTo(
Generation,
fullCellId,
dynamic: false,
destination);
public int GetCellDynamicCount(uint fullCellId) =>
Source.GetCellCount(Generation, fullCellId, dynamic: true);
public int CopyCellDynamicsTo(
uint fullCellId,
Span<RenderProjectionRecord> destination) =>
Source.CopyCellTo(
Generation,
fullCellId,
dynamic: true,
destination);
private IRenderSceneQuerySource Source => private IRenderSceneQuerySource Source =>
_source _source
?? throw new InvalidOperationException("The render-scene query is uninitialized."); ?? throw new InvalidOperationException("The render-scene query is uninitialized.");

View file

@ -9,70 +9,70 @@ namespace AcDream.App.Rendering.Walk;
/// <summary> /// <summary>
/// Campaign FW3.2b-2: the production <see cref="IWalkFrameWorldData"/> over /// Campaign FW3.2b-2: the production <see cref="IWalkFrameWorldData"/> over
/// the retained scene (<see cref="RenderSceneQuery"/>) and the FW3.1 /// the retained scene (<see cref="RenderSceneQuery"/>) and the FW3.1
/// <see cref="WalkBuildingRegistry"/>. Rebuilt facts per frame via /// <see cref="WalkBuildingRegistry"/>.
/// <see cref="BeginFrame"/>:
/// ///
/// <list type="bullet"> /// <para>
/// <item>Cell statics — ONE <see cref="RenderSceneQuery.CopyIndexTo"/> sweep /// Campaign OVERHAUL S2 chunk 5: cell membership is a BORROWED VIEW over
/// over authored indoor statics, bucketed into every cell of /// <see cref="ShadowObjectRegistry"/>'s retail per-cell part-entry product
/// <see cref="ShadowObjectRegistry.TryGetRetailCellArray"/>'s retail /// (<see cref="ShadowObjectRegistry.GetRetailPartEntriesInCell"/> — retail's
/// CELLARRAY. Campaign OVERHAUL S2 chunk 2: membership is BORROWED from that /// <c>CPartArray::AddPartsShadow</c> 0x00517e40 output, keyed by the SAME
/// registry — the sole owner of retail's <c>calc_cross_cells_static</c> /// CELLARRAY <see cref="ShadowObjectRegistry.TryGetRetailCellArray"/>
/// (0x00515160) → <c>CPartArray::AddPartsShadow</c> (0x00517e40) transaction /// answers). This class no longer sweeps the scene's static/dynamic indices
/// — never rebuilt in this class. A registered entity's array deliberately /// to REBUILD membership: for a queried cell id, <see cref="ResolveCellView"/>
/// includes non-colliding decorations and is distinct from the physics /// walks the registry's per-cell entries (already in retail
/// shadow-object index (<see cref="ShadowObjectRegistry.GetOwnerCells"/>). /// CELLARRAY-then-part-array insertion order), collapses them to their
/// An entity the registry has not registered yet (the streaming window /// distinct owning entity ids in that same order, and resolves each id back
/// where the static-projection journal published the record before the /// to its current <see cref="RenderProjectionRecord"/> through
/// physics publisher registered the entity — two independent incremental /// <see cref="RenderSceneQuery.TryGetByLocalEntityId"/> — a presentation-side
/// state machines) falls back to its authored /// lookup by the SAME <see cref="RenderSourceMetadata.LocalEntityId"/> the
/// <see cref="RenderSourceMetadata.ParentCellId"/> alone and is counted in /// registry is keyed by (chunk 5's App-side addition to
/// <see cref="UnregisteredStaticRenderFallbackCount"/>. Per-cell PART /// <c>ArchRenderScene</c>). Statics vs. dynamics are the same
/// entries (<see cref="ShadowObjectRegistry.GetRetailPartEntriesInCell"/>) /// <c>ProjectionClass</c> split <c>ArchRenderScene</c> itself uses (a live
/// are not consumed here — buckets stay whole-record per cell, which equals /// dynamic root or an equipped child is "dynamic"; every other class is
/// CELLARRAY membership; per-part draw admission is Campaign OVERHAUL S3.</item> /// "static"), and building shells are excluded — they draw at their own
/// <item>Live dynamics — ONE global dynamic-index sweep, bucketed into every /// building's shell turn (retail <c>CPhysicsPart::Draw(parts, 0)</c>
/// cell in the object's retained physics <c>CELLARRAY</c>. Retail feeds that /// @0x0059f331), never at the cell's ordinary <c>DrawObjCell</c> turn.
/// exact array to <c>CPhysicsObj::add_shadows_to_cells</c>, which calls /// </para>
/// <c>CPartArray::AddPartsShadow</c> for every member cell. A creature crossing
/// a stair portal must therefore remain drawable from both the feet cell and
/// the head cell; indexing only by its authored parent makes individual body
/// parts disappear at the portal edge.</item>
/// <item>Outdoor statics — ONE <see cref="RenderSceneQuery.CopyIndexTo"/>
/// sweep bucketed the same way as cell statics — the registry's retail
/// CELLARRAY first, falling back to
/// <see cref="ShadowObjectRegistry.GetOwnerCells"/> (counted the same way)
/// when unregistered — EXCLUDING building shells (they draw at their
/// building's own shell turn, retail <c>CPhysicsPart::Draw(parts, 0)</c>
/// @0x0059f331, not at the cell's <c>DrawObjCell</c> turn).</item>
/// <item>Building shells — the same sweep's <c>IsBuildingShell</c> records
/// bucketed by <c>Source.BuildingShellAnchorCellId</c> for portal-bearing
/// buildings. Portal-less buildings have no interior anchor; retail still
/// draws them at their landscape position-cell turn, so those records use
/// <c>Source.EffectCellId</c>, matching <see cref="WalkBuilding.PositionCellId"/>.</item>
/// </list>
/// ///
/// The tuple landblock id handed to the classifier is the frame's player /// <para>
/// landblock, matching the production walk's retained-scene query convention. /// An entity the registry HAS flooded into this cell but whose projected
/// record the presentation journal has not applied yet this frame (the
/// transient race between <c>LiveEntityRuntime</c>'s projection journal and
/// <c>ShadowObjectRegistry</c>'s physics-side registration, both driven off
/// the same Create/appearance edge but landing through independent
/// incremental pipelines) contributes to NO cell — retail draws nothing for
/// an object not yet in a cell; there is no second, conservative fallback.
/// Every DISTINCT entity id this happens for in one frame is counted once in
/// <see cref="UnregisteredRenderMembershipCount"/>, regardless of how many
/// cells its CELLARRAY touches or how many <c>Get*</c> calls observe it.
/// </para>
/// ///
/// <para>Campaign FW3.4a: <see cref="GetCellStatics"/>, <see cref="GetOutdoorStatics"/>, /// <para>Building shells are the ONE exception left to a per-frame sweep:
/// and <see cref="GetBuildingShellStatics"/> used to materialize their result /// retail's shell/portal machinery is out of S2's scope (the landcell
/// with <c>_cellScratch[..count]</c> / <c>[.. bucket]</c> — a FRESH /// building channel), so <see cref="GetBuildingShellStatics"/> still reads a
/// <c>RenderProjectionRecord[]</c> allocation per distinct cell/anchor per /// small per-frame bucket (<c>_shellsByAnchor</c>) filled by a single narrow
/// frame. At a town-density frame (dozens of cells) that was the single /// <see cref="RenderSceneIndex.OutdoorStatic"/> sweep in
/// largest contributor to the FW3.4 perf checkpoint's 14× frame-allocation /// <see cref="BeginFrame"/> that keeps only <c>IsBuildingShell</c> records —
/// regression (1.9 MB/frame p50). <see cref="_arena"/> replaces it: a /// it never resolves ordinary static/dynamic membership.</para>
/// grow-only buffer, reset to length 0 once per frame in ///
/// <see cref="BeginFrame"/>, that every materialization call /// <para>Campaign FW3.4a: <see cref="GetCellStatics"/>, <see cref="GetCellDynamics"/>,
/// <see cref="AppendToArena"/>s its records into instead of snapshotting a /// <see cref="GetOutdoorStatics"/>, <see cref="GetOutdoorDynamics"/>, and
/// new array — after the arena reaches its steady-state size (a few frames /// <see cref="GetBuildingShellStatics"/> materialize their result into
/// of warmup, same shape as <see cref="_sweepScratch"/>/<see cref="_cellScratch"/>'s /// <see cref="_arena"/> — a grow-only buffer reset to length 0 once per frame
/// existing grow-on-demand pattern), zero further heap allocation occurs /// in <see cref="BeginFrame"/> — rather than a fresh <c>RenderProjectionRecord[]</c>
/// here. Every <see cref="WalkFrameStaticRecords.Records"/> segment is /// allocation per distinct cell/anchor per frame (the single largest
/// contributor to the FW3.4 perf checkpoint's 14× frame-allocation
/// regression, 1.9 MB/frame p50, before the arena existed).
/// <see cref="_cellViewScratch"/> is the matching grow-on-demand scratch
/// buffer <see cref="ResolveCellView"/> collects one cell's filtered records
/// into before a single <see cref="AppendToArena"/> call — the same
/// steady-state-zero-allocation shape <see cref="_sweepScratch"/> already
/// has. Every <see cref="WalkFrameStaticRecords.Records"/> segment is
/// STRICTLY per-frame scratch — nothing holds one across a frame boundary /// STRICTLY per-frame scratch — nothing holds one across a frame boundary
/// (the driver/populator consume it immediately, matching /// (the driver/populator consume it immediately) — so reusing the same
/// <see cref="_sweepScratch"/>'s existing lifetime contract) — so reusing the /// backing array's memory next frame is safe. Per-cell RESULTS
/// same backing array's memory next frame is safe.</para> /// (<see cref="_cellCache"/> etc.) are cleared and re-materialized once per
/// frame on first ask, same as before chunk 5.</para>
/// </summary> /// </summary>
internal sealed class WalkProductionWorldData : IWalkFrameWorldData internal sealed class WalkProductionWorldData : IWalkFrameWorldData
{ {
@ -80,24 +80,32 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
private readonly ShadowObjectRegistry _shadows; private readonly ShadowObjectRegistry _shadows;
private RenderSceneQuery _scene; private RenderSceneQuery _scene;
private uint _tupleLandblockId; private uint _tupleLandblockId;
private int _renderCenterLbX;
private int _renderCenterLbY;
private readonly Dictionary<uint, WalkFrameStaticRecords> _cellCache = new(); private readonly Dictionary<uint, WalkFrameStaticRecords> _cellCache = new();
private readonly Dictionary<uint, WalkFrameStaticRecords> _cellDynamicCache = new(); private readonly Dictionary<uint, WalkFrameStaticRecords> _cellDynamicCache = new();
private readonly Dictionary<string, string> _facilityShadowProbeSignatures = new(); private readonly Dictionary<string, string> _facilityShadowProbeSignatures = new();
private readonly Dictionary<uint, List<RenderProjectionRecord>> _indoorByCell = new();
private readonly Dictionary<uint, List<RenderProjectionRecord>> _indoorDynamicsByCell = new();
private readonly Dictionary<uint, List<RenderProjectionRecord>> _outdoorByCell = new();
private readonly Dictionary<uint, List<RenderProjectionRecord>> _outdoorDynamicsByCell = new();
private readonly Dictionary<uint, List<RenderProjectionRecord>> _shellsByAnchor = new(); private readonly Dictionary<uint, List<RenderProjectionRecord>> _shellsByAnchor = new();
private readonly Dictionary<uint, WalkFrameStaticRecords> _outdoorMaterialized = new(); private readonly Dictionary<uint, WalkFrameStaticRecords> _outdoorMaterialized = new();
private readonly Dictionary<uint, WalkFrameStaticRecords> _outdoorDynamicsMaterialized = new(); private readonly Dictionary<uint, WalkFrameStaticRecords> _outdoorDynamicsMaterialized = new();
private readonly Dictionary<uint, WalkFrameStaticRecords> _shellMaterialized = new(); private readonly Dictionary<uint, WalkFrameStaticRecords> _shellMaterialized = new();
private readonly Func<uint, (bool Found, IReadOnlyList<uint> Cells)> _tryGetRetailCellArray;
private RenderProjectionRecord[] _indoorSweepScratch = new RenderProjectionRecord[256]; // Campaign OVERHAUL S2 chunk 5: every distinct entity id counted into
// UnregisteredRenderMembershipCount this frame, so the SAME entity
// touching several cells (or being asked for through both the static and
// dynamic Get* pair) is counted once — not once per cell visit. Cleared
// in BeginFrame alongside the counter itself.
private readonly HashSet<uint> _unregisteredEntitiesThisFrame = new();
// The one surviving per-frame sweep: building shells only (see this
// type's own doc comment). Statics/dynamics no longer sweep the scene at
// all — membership is a borrowed, on-demand view over the registry.
private RenderProjectionRecord[] _sweepScratch = new RenderProjectionRecord[1024]; private RenderProjectionRecord[] _sweepScratch = new RenderProjectionRecord[1024];
private RenderProjectionRecord[] _dynamicSweepScratch = new RenderProjectionRecord[256];
// Campaign OVERHAUL S2 chunk 5: ResolveCellView's grow-on-demand scratch
// buffer — collects one cell's filtered records before a single
// AppendToArena call. Starts small: a cell's real membership is usually
// a handful of parts, not the hundreds an old full-scene sweep held.
private RenderProjectionRecord[] _cellViewScratch = new RenderProjectionRecord[64];
// Campaign FW3.4a: the per-frame, grow-only materialization arena — see // Campaign FW3.4a: the per-frame, grow-only materialization arena — see
// this type's own doc comment. // this type's own doc comment.
@ -110,455 +118,135 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
{ {
_buildings = buildings ?? throw new ArgumentNullException(nameof(buildings)); _buildings = buildings ?? throw new ArgumentNullException(nameof(buildings));
_shadows = shadows ?? throw new ArgumentNullException(nameof(shadows)); _shadows = shadows ?? throw new ArgumentNullException(nameof(shadows));
_tryGetRetailCellArray = TryGetRetailCellArrayForEntity;
}
private (bool Found, IReadOnlyList<uint> Cells) TryGetRetailCellArrayForEntity(uint entityId)
{
bool found = _shadows.TryGetRetailCellArray(entityId, out IReadOnlyList<uint> cells);
return (found, cells);
} }
/// <summary> /// <summary>
/// Campaign OVERHAUL S2 chunk 2: how many indoor/outdoor static records /// Campaign OVERHAUL S2 chunk 5: how many DISTINCT entities this frame
/// this frame fell back to a conservative single-cell membership /// were present in the registry's retail CELLARRAY (so
/// (indoor: the authored <see cref="RenderSourceMetadata.ParentCellId"/>; /// <see cref="ShadowObjectRegistry.GetRetailPartEntriesInCell"/> named
/// outdoor: today's collision-flood /// them) but had no resolvable <see cref="RenderProjectionRecord"/> yet
/// <see cref="ShadowObjectRegistry.GetOwnerCells"/> answer) because /// through <see cref="RenderSceneQuery.TryGetByLocalEntityId"/> — the
/// <see cref="ShadowObjectRegistry.TryGetRetailCellArray"/> had no retail /// transient streaming window where the physics publisher's registration
/// CELLARRAY registered yet for the entity. Retail has no such gap — /// outran the presentation journal's applied projection for the same
/// <c>CEnvCell::init_static_objects</c> installs the CELLARRAY before a /// Create/appearance edge. Retail has no such gap
/// static is ever drawable — so a nonzero count here reflects two /// (<c>CEnvCell::init_static_objects</c> installs the CELLARRAY before a
/// independent incremental state machines (the static-projection journal /// static is ever drawable) so a nonzero count here reflects two
/// versus the physics publisher) racing during streaming, not /// independent incremental state machines racing during streaming, not
/// steady-state behavior; see AD-40's residency reasoning in /// steady-state behavior; filed as AD-116, citing the same residency-race
/// <see cref="CellTransit.BuildShadowCellSetFromParts"/>. Reset to zero /// reasoning AD-49 already accepts for
/// at the start of every <see cref="BeginFrame"/>. Campaign OVERHAUL S2 /// <see cref="CellTransit.BuildShadowCellSetFromParts"/>'s own outdoor
/// chunk 5 decides this adaptation's fate against the connected-route /// seed. Such an entity
/// count. /// contributes to NO cell this frame — there is no second, conservative
/// fallback (chunk 5 removed the authored-parent-cell and
/// root-position-cell fallbacks chunk 2/4 carried). Reset to zero at the
/// start of every <see cref="BeginFrame"/>, after that PRIOR frame's
/// value has already been reported by the diagnostic line below.
/// </summary> /// </summary>
public int UnregisteredStaticRenderFallbackCount { get; private set; } public int UnregisteredRenderMembershipCount { get; private set; }
/// <summary>Rebuilds the frame's outdoor/shell buckets and clears the /// <summary>Rebuilds the frame's building-shell bucket and clears the
/// per-cell cache. Call once per frame before the driver runs. /// per-cell caches. Call once per frame before the driver runs.
/// <paramref name="renderCenterLbX"/>/<paramref name="renderCenterLbY"/> /// <paramref name="renderCenterLbX"/>/<paramref name="renderCenterLbY"/>
/// are the streaming recenter origin: record positions are /// were the streaming recenter origin the deleted position-based
/// RENDER-ORIGIN-RELATIVE (each landblock's entities carry /// fallbacks used to convert a render-origin-relative position back to
/// <c>(lbX CenterX)·192</c> offsets), so mapping a position back to /// its true landblock byte (<c>LandscapeCellId</c>). Campaign OVERHAUL
/// its TRUE landblock byte needs the center added back — the first /// S2 chunk 5 deleted every position-based fallback — the registry's
/// connected gate of the FW3.2b-2 cutover shipped without this and most /// CELLARRAY is already expressed in exact cell ids — so these two
/// outdoor scenery landed in garbage buckets no walk turn ever reads.</summary> /// parameters are accepted (unchanged call-site signature) but no
/// longer stored or read.</summary>
internal void BeginFrame( internal void BeginFrame(
RenderSceneQuery scene, RenderSceneQuery scene,
uint tupleLandblockId, uint tupleLandblockId,
int renderCenterLbX, int renderCenterLbX,
int renderCenterLbY) int renderCenterLbY)
{ {
// Campaign OVERHAUL S2 chunk 5: report the PRIOR frame's count
// before resetting it — the count is only final once that frame's
// walk (a sequence of on-demand Get* calls this class has no other
// "frame is done" hook for) has finished, so this is necessarily a
// one-frame-delayed report, matching every other per-second/per-
// change probe in this file family (print-only; never influences
// admission).
if (UnregisteredRenderMembershipCount > 0
&& AcDream.Core.Rendering.RenderingDiagnostics.ProbeFacilityStairsEnabled)
{
Console.WriteLine(
$"[walk-membership] unregistered={UnregisteredRenderMembershipCount} "
+ $"tupleLandblock=0x{_tupleLandblockId:X8}");
}
_scene = scene; _scene = scene;
_tupleLandblockId = tupleLandblockId; _tupleLandblockId = tupleLandblockId;
_renderCenterLbX = renderCenterLbX;
_renderCenterLbY = renderCenterLbY;
_cellCache.Clear(); _cellCache.Clear();
_cellDynamicCache.Clear(); _cellDynamicCache.Clear();
_outdoorMaterialized.Clear(); _outdoorMaterialized.Clear();
_outdoorDynamicsMaterialized.Clear(); _outdoorDynamicsMaterialized.Clear();
_shellMaterialized.Clear(); _shellMaterialized.Clear();
_arenaLength = 0; _arenaLength = 0;
UnregisteredStaticRenderFallbackCount = 0; UnregisteredRenderMembershipCount = 0;
foreach (List<RenderProjectionRecord> bucket in _indoorByCell.Values) _unregisteredEntitiesThisFrame.Clear();
bucket.Clear();
foreach (List<RenderProjectionRecord> bucket in _indoorDynamicsByCell.Values)
bucket.Clear();
foreach (List<RenderProjectionRecord> bucket in _outdoorByCell.Values)
bucket.Clear();
foreach (List<RenderProjectionRecord> bucket in _outdoorDynamicsByCell.Values)
bucket.Clear();
foreach (List<RenderProjectionRecord> bucket in _shellsByAnchor.Values) foreach (List<RenderProjectionRecord> bucket in _shellsByAnchor.Values)
bucket.Clear(); bucket.Clear();
// Retail CEnvCell::init_static_objects does not leave an object solely // Building shells are the landcell BUILDING channel (out of S2):
// in its authored parent cell. add_obj_to_cell -> // retail draws a shell at its own DrawBuilding turn, never through
// calc_cross_cells_static -> CPartArray::AddPartsShadow registers all // the cell's ordinary shadow_part_list walk (Contract C), so this
// visual parts in every crossed cell, including parts with no physics // is the one sweep that survives chunk 5 — it exists ONLY to bucket
// BSP. Campaign OVERHAUL S2 chunk 2: that membership is borrowed // IsBuildingShell records by their authored anchor, never to
// whole from ShadowObjectRegistry's retained retail CELLARRAY — never // resolve ordinary static/dynamic cell membership.
// recomputed here. int required = _scene.IndexCounts.For(RenderSceneIndex.OutdoorStatic);
int required = _scene.IndexCounts.For(RenderSceneIndex.IndoorCellStatic);
if (required > _indoorSweepScratch.Length)
{
_indoorSweepScratch = new RenderProjectionRecord[
Math.Max(required, _indoorSweepScratch.Length * 2)];
}
int count = _scene.CopyIndexTo(
RenderSceneIndex.IndoorCellStatic,
_indoorSweepScratch);
for (int i = 0; i < count; i++)
{
ref readonly RenderProjectionRecord record = ref _indoorSweepScratch[i];
IReadOnlyList<uint> renderCells = ResolveIndoorStaticRenderCells(
in record, _tryGetRetailCellArray, out bool usedFallback);
if (usedFallback)
UnregisteredStaticRenderFallbackCount++;
BucketIndoorRecord(
in record,
renderCells,
_indoorByCell,
_outdoorByCell);
}
// CopyIndexTo THROWS on an undersized destination (ArchRenderScene
// validates up front — the first connected gate run of the FW3.2b-2
// cutover crashed on exactly this at Aerlinthe's 5,040 outdoor
// statics), so presize from the query's own index counts.
required = _scene.IndexCounts.For(RenderSceneIndex.OutdoorStatic);
if (required > _sweepScratch.Length) if (required > _sweepScratch.Length)
{ {
_sweepScratch = new RenderProjectionRecord[ _sweepScratch = new RenderProjectionRecord[
Math.Max(required, _sweepScratch.Length * 2)]; Math.Max(required, _sweepScratch.Length * 2)];
} }
count = _scene.CopyIndexTo(RenderSceneIndex.OutdoorStatic, _sweepScratch); int count = _scene.CopyIndexTo(RenderSceneIndex.OutdoorStatic, _sweepScratch);
for (int i = 0; i < count; i++) for (int i = 0; i < count; i++)
{ {
ref readonly RenderProjectionRecord record = ref _sweepScratch[i]; ref readonly RenderProjectionRecord record = ref _sweepScratch[i];
if (record.EntityPayload.IsBuildingShell) if (!record.EntityPayload.IsBuildingShell)
{ continue;
uint anchor = BuildingShellBucketCellId(in record); uint anchor = BuildingShellBucketCellId(in record);
if (!_shellsByAnchor.TryGetValue(anchor, out List<RenderProjectionRecord>? shells)) if (!_shellsByAnchor.TryGetValue(anchor, out List<RenderProjectionRecord>? shells))
_shellsByAnchor[anchor] = shells = new List<RenderProjectionRecord>(); _shellsByAnchor[anchor] = shells = new List<RenderProjectionRecord>();
shells.Add(record); shells.Add(record);
}
}
/// <summary>Facility Hub / cathedral discriminator: does the given cell's
/// borrowed retail render membership contain a record with this exact
/// authored SourceId? Reimplemented directly over the registry's
/// per-cell entries (chunk 5) rather than reading a swept bucket
/// dictionary — <see cref="RetailPViewRenderer"/>'s
/// <c>ProbeFacilityStairsEnabled</c> block is the only caller.</summary>
internal bool StaticBucketContains(uint cellId, uint sourceId)
{
IReadOnlyList<RetailPartEntry> entries =
_shadows.GetRetailPartEntriesInCell(cellId);
uint previousEntityId = 0;
bool havePrevious = false;
for (int i = 0; i < entries.Count; i++)
{
uint entityId = entries[i].EntityId;
if (havePrevious && entityId == previousEntityId)
continue; continue;
} previousEntityId = entityId;
IReadOnlyList<uint> renderCells = ResolveOutdoorStaticRenderCells( havePrevious = true;
in record, _tryGetRetailCellArray, _shadows.GetOwnerCells, out bool usedFallback);
if (usedFallback)
UnregisteredStaticRenderFallbackCount++;
BucketOutdoorRecord(
in record,
renderCells,
_outdoorByCell,
_renderCenterLbX,
_renderCenterLbY);
}
// Retail CPhysicsObj::add_shadows_to_cells installs every PartArray in if (_scene.TryGetByLocalEntityId(entityId, out RenderProjectionRecord record)
// every cell from calc_cross_cells' retained CELLARRAY. The scene's && record.Source.SourceId == sourceId)
// parent-cell dictionary cannot represent that membership, so consume
// the global dynamic index once and rebuild both indoor and outdoor
// render buckets from ShadowObjectRegistry's exact retained array.
required = _scene.IndexCounts.For(RenderSceneIndex.Dynamic);
if (required > _dynamicSweepScratch.Length)
{ {
_dynamicSweepScratch = new RenderProjectionRecord[ return true;
Math.Max(required, _dynamicSweepScratch.Length * 2)];
}
count = _scene.CopyIndexTo(
RenderSceneIndex.Dynamic,
_dynamicSweepScratch);
for (int i = 0; i < count; i++)
{
ref readonly RenderProjectionRecord record = ref _dynamicSweepScratch[i];
IReadOnlyList<uint> renderCells = ResolveDynamicRenderCells(
in record,
_tryGetRetailCellArray,
_shadows.GetOwnerCells,
out bool usedFallback);
if (usedFallback)
UnregisteredStaticRenderFallbackCount++;
BucketDynamicRecord(
in record,
renderCells,
_indoorDynamicsByCell,
_outdoorDynamicsByCell,
_renderCenterLbX,
_renderCenterLbY);
} }
} }
return false;
/// <summary>
/// Campaign OVERHAUL S2 chunk 4: one dynamic record's render-cell
/// membership, borrowed from <see cref="ShadowObjectRegistry.TryGetRetailCellArray"/>
/// — the SAME direct read <see cref="ResolveOutdoorStaticRenderCells"/>
/// uses. An equipped child's retail array is now published by
/// <see cref="ShadowObjectRegistry.AttachChild"/> at the registry (retail
/// Contract B's <c>add_shadows_to_cells</c> child-inheritance recursion),
/// so this no longer needs its own render-side parent-chain walk — the
/// registry already resolved a nested attachment to its ultimate root.
/// When the registry has no retail array yet for this entity (the same
/// streaming-window race <see cref="ResolveIndoorStaticRenderCells"/>
/// documents), the fallback is today's collision-flood
/// <see cref="ShadowObjectRegistry.GetOwnerCells"/> answer, counted the
/// same way as the static fallbacks via <paramref name="usedFallback"/>.
/// </summary>
internal static IReadOnlyList<uint> ResolveDynamicRenderCells(
in RenderProjectionRecord record,
Func<uint, (bool Found, IReadOnlyList<uint> Cells)> tryGetRetailCellArray,
Func<uint, IReadOnlyList<uint>> getOwnerCells,
out bool usedFallback)
{
ArgumentNullException.ThrowIfNull(tryGetRetailCellArray);
ArgumentNullException.ThrowIfNull(getOwnerCells);
(bool found, IReadOnlyList<uint> cells) =
tryGetRetailCellArray(record.Source.LocalEntityId);
if (found)
{
usedFallback = false;
return cells;
}
usedFallback = true;
return getOwnerCells(record.Source.LocalEntityId);
}
/// <summary>
/// Campaign OVERHAUL S2 chunk 2: one authored indoor static's render-cell
/// membership, borrowed from <see cref="ShadowObjectRegistry.TryGetRetailCellArray"/>
/// — retail's <c>calc_cross_cells_static</c> (0x00515160) →
/// <c>CPartArray::AddPartsShadow</c> (0x00517e40) CELLARRAY. When the
/// registry has no retail array yet for this entity (the streaming window
/// where the static-projection journal published the record before the
/// physics publisher registered the entity — two independent incremental
/// state machines; AD-40's residency reasoning in
/// <see cref="CellTransit.BuildShadowCellSetFromParts"/>), the LAST-RESORT
/// fallback is the authored parent cell alone, and
/// <paramref name="usedFallback"/> reports it so the caller can count it.
/// </summary>
internal static IReadOnlyList<uint> ResolveIndoorStaticRenderCells(
in RenderProjectionRecord record,
Func<uint, (bool Found, IReadOnlyList<uint> Cells)> tryGetRetailCellArray,
out bool usedFallback)
{
ArgumentNullException.ThrowIfNull(tryGetRetailCellArray);
(bool found, IReadOnlyList<uint> cells) =
tryGetRetailCellArray(record.Source.LocalEntityId);
if (found)
{
usedFallback = false;
return cells;
}
usedFallback = true;
return record.Source.ParentCellId != 0u
? new[] { record.Source.ParentCellId }
: Array.Empty<uint>();
}
/// <summary>
/// Campaign OVERHAUL S2 chunk 2: one authored outdoor static's
/// render-cell membership, borrowed from
/// <see cref="ShadowObjectRegistry.TryGetRetailCellArray"/> the same way
/// as <see cref="ResolveIndoorStaticRenderCells"/>. The fallback for an
/// entity the registry has not registered yet is today's collision-flood
/// <see cref="ShadowObjectRegistry.GetOwnerCells"/> answer (itself
/// possibly empty — <see cref="BucketOutdoorRecord"/> already carries its
/// own root-position-cell fallback for that case), counted the same way
/// as the indoor path via <paramref name="usedFallback"/>.
/// </summary>
internal static IReadOnlyList<uint> ResolveOutdoorStaticRenderCells(
in RenderProjectionRecord record,
Func<uint, (bool Found, IReadOnlyList<uint> Cells)> tryGetRetailCellArray,
Func<uint, IReadOnlyList<uint>> getOwnerCells,
out bool usedFallback)
{
ArgumentNullException.ThrowIfNull(tryGetRetailCellArray);
ArgumentNullException.ThrowIfNull(getOwnerCells);
(bool found, IReadOnlyList<uint> cells) =
tryGetRetailCellArray(record.Source.LocalEntityId);
if (found)
{
usedFallback = false;
return cells;
}
usedFallback = true;
return getOwnerCells(record.Source.LocalEntityId);
}
internal bool StaticBucketContains(uint cellId, uint sourceId) =>
_indoorByCell.TryGetValue(
cellId,
out List<RenderProjectionRecord>? records)
&& records.Exists(record => record.Source.SourceId == sourceId);
/// <summary>
/// Buckets one authored indoor static into the render cell lists produced
/// by retail's cross-cell PartArray walk. Outdoor cells are routed to the
/// landscape turn because a visual part may cross an exit portal.
/// </summary>
internal static void BucketIndoorRecord(
in RenderProjectionRecord record,
IReadOnlyList<uint> renderCells,
Dictionary<uint, List<RenderProjectionRecord>> indoorBuckets,
Dictionary<uint, List<RenderProjectionRecord>> outdoorBuckets)
{
ArgumentNullException.ThrowIfNull(renderCells);
ArgumentNullException.ThrowIfNull(indoorBuckets);
ArgumentNullException.ThrowIfNull(outdoorBuckets);
bool added = false;
for (int i = 0; i < renderCells.Count; i++)
{
uint cellId = renderCells[i];
uint low = cellId & 0xFFFFu;
if (low is >= 1u and <= 64u)
{
AddToBucket(in record, cellId, outdoorBuckets);
added = true;
}
else if (low >= 0x100u)
{
AddToBucket(in record, cellId, indoorBuckets);
added = true;
}
}
if (!added && record.Source.ParentCellId != 0u)
AddToBucket(in record, record.Source.ParentCellId, indoorBuckets);
}
/// <summary>
/// Installs one live PartArray into every cell in retail's retained
/// <c>CELLARRAY</c>. Interior and landscape memberships can coexist while
/// crossing a building exit. If the collision owner is not registered yet
/// (or the object is a visual-only effect), the authored interior parent
/// or outdoor root-position cell remains the conservative fallback.
/// </summary>
internal static void BucketDynamicRecord(
in RenderProjectionRecord record,
IReadOnlyList<uint> shadowCells,
Dictionary<uint, List<RenderProjectionRecord>> indoorBuckets,
Dictionary<uint, List<RenderProjectionRecord>> outdoorBuckets,
int renderCenterLbX,
int renderCenterLbY)
{
ArgumentNullException.ThrowIfNull(shadowCells);
ArgumentNullException.ThrowIfNull(indoorBuckets);
ArgumentNullException.ThrowIfNull(outdoorBuckets);
bool added = false;
for (int i = 0; i < shadowCells.Count; i++)
{
uint cellId = shadowCells[i];
uint low = cellId & 0xFFFFu;
if (low is >= 1u and <= 64u)
{
AddToBucket(in record, cellId, outdoorBuckets);
added = true;
}
else if (low >= 0x100u)
{
AddToBucket(in record, cellId, indoorBuckets);
added = true;
}
}
if (added)
return;
uint parentLow = record.Source.ParentCellId & 0xFFFFu;
if (record.Source.ParentCellId != 0u && parentLow >= 0x100u)
{
AddToBucket(in record, record.Source.ParentCellId, indoorBuckets);
return;
}
uint outdoorCell = LandscapeCellId(
record.Transform.Position,
renderCenterLbX,
renderCenterLbY);
AddToBucket(in record, outdoorCell, outdoorBuckets);
}
/// <summary>
/// Installs one outdoor object's render shadow in every outdoor cell of
/// its authoritative physics <c>CELLARRAY</c>. This is retail's
/// <c>CPhysicsObj::add_shadows_to_cells</c> →
/// <c>CPartArray::AddPartsShadow</c> path: a large object straddling a
/// landblock edge must remain reachable when its origin cell leaves the
/// landscape walk. Objects without a collision registration (notably
/// short-lived visual effects) retain the root-position fallback.
/// </summary>
internal static void BucketOutdoorRecord(
in RenderProjectionRecord record,
IReadOnlyList<uint> shadowCells,
Dictionary<uint, List<RenderProjectionRecord>> buckets,
int renderCenterLbX,
int renderCenterLbY)
{
ArgumentNullException.ThrowIfNull(shadowCells);
ArgumentNullException.ThrowIfNull(buckets);
bool added = false;
for (int i = 0; i < shadowCells.Count; i++)
{
uint cellId = shadowCells[i];
uint cellIndex = cellId & 0xFFFFu;
if (cellIndex is < 1u or > 64u)
continue;
AddToBucket(in record, cellId, buckets);
added = true;
}
if (!added)
{
uint cellId = LandscapeCellId(
record.Transform.Position,
renderCenterLbX,
renderCenterLbY);
AddToBucket(in record, cellId, buckets);
}
}
private static void AddToBucket(
in RenderProjectionRecord record,
uint cellId,
Dictionary<uint, List<RenderProjectionRecord>> buckets)
{
if (!buckets.TryGetValue(
cellId,
out List<RenderProjectionRecord>? bucket))
{
buckets[cellId] = bucket = new List<RenderProjectionRecord>();
}
bucket.Add(record);
}
/// <summary>The landscape cell owning a RENDER-ORIGIN-RELATIVE position
/// — retail's 24 m cell grid inside the 192 m landblock, producing the
/// same TRUE <c>(lb &amp; 0xFFFF0000) | (cellX*8 + cellY + 1)</c> encoding
/// the walk's landscape turn emits: the relative block index
/// (<c>floor(p/192)</c>) plus the streaming center recovers the true
/// landblock byte, because entity positions carry
/// <c>(lbX CenterX)·192</c> world offsets
/// (<c>LandblockBuildFactory</c>'s <c>worldOffset</c>).</summary>
internal static uint LandscapeCellId(
Vector3 relativePosition, int renderCenterLbX, int renderCenterLbY)
{
int relBlockX = (int)MathF.Floor(relativePosition.X / 192f);
int relBlockY = (int)MathF.Floor(relativePosition.Y / 192f);
float localX = relativePosition.X - relBlockX * 192f;
float localY = relativePosition.Y - relBlockY * 192f;
int cellX = Math.Clamp((int)(localX / 24f), 0, 7);
int cellY = Math.Clamp((int)(localY / 24f), 0, 7);
uint landblock =
((uint)(byte)(renderCenterLbX + relBlockX) << 24)
| ((uint)(byte)(renderCenterLbY + relBlockY) << 16);
return landblock | (uint)(cellX * 8 + cellY + 1);
} }
public WalkFrameStaticRecords GetCellStatics(uint cellId) public WalkFrameStaticRecords GetCellStatics(uint cellId)
{ {
if (_cellCache.TryGetValue(cellId, out WalkFrameStaticRecords cached)) if (_cellCache.TryGetValue(cellId, out WalkFrameStaticRecords cached))
return cached; return cached;
WalkFrameStaticRecords records = WalkFrameStaticRecords records = ResolveCellView(cellId, dynamic: false);
_indoorByCell.TryGetValue(cellId, out List<RenderProjectionRecord>? bucket)
&& bucket.Count > 0
? new WalkFrameStaticRecords(
AppendToArena(CollectionsMarshal.AsSpan(bucket)), _tupleLandblockId)
: WalkFrameStaticRecords.Empty with { TupleLandblockId = _tupleLandblockId };
EmitFacilityShadowProbe(records.Records, cellId, "static"); EmitFacilityShadowProbe(records.Records, cellId, "static");
_cellCache[cellId] = records; _cellCache[cellId] = records;
return records; return records;
@ -568,19 +256,112 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
{ {
if (_cellDynamicCache.TryGetValue(cellId, out WalkFrameStaticRecords cached)) if (_cellDynamicCache.TryGetValue(cellId, out WalkFrameStaticRecords cached))
return cached; return cached;
WalkFrameStaticRecords records = WalkFrameStaticRecords records = ResolveCellView(cellId, dynamic: true);
_indoorDynamicsByCell.TryGetValue(
cellId,
out List<RenderProjectionRecord>? bucket)
&& bucket.Count > 0
? new WalkFrameStaticRecords(
AppendToArena(CollectionsMarshal.AsSpan(bucket)), _tupleLandblockId)
: WalkFrameStaticRecords.Empty with { TupleLandblockId = _tupleLandblockId };
EmitFacilityShadowProbe(records.Records, cellId, "dynamic"); EmitFacilityShadowProbe(records.Records, cellId, "dynamic");
_cellDynamicCache[cellId] = records; _cellDynamicCache[cellId] = records;
return records; return records;
} }
public WalkFrameStaticRecords GetOutdoorStatics(uint cellId)
{
if (_outdoorMaterialized.TryGetValue(cellId, out WalkFrameStaticRecords cached))
return cached;
WalkFrameStaticRecords records = ResolveCellView(cellId, dynamic: false);
_outdoorMaterialized[cellId] = records;
return records;
}
public WalkFrameStaticRecords GetOutdoorDynamics(uint cellId)
{
if (_outdoorDynamicsMaterialized.TryGetValue(
cellId,
out WalkFrameStaticRecords cached))
{
return cached;
}
WalkFrameStaticRecords records = ResolveCellView(cellId, dynamic: true);
_outdoorDynamicsMaterialized[cellId] = records;
return records;
}
/// <summary>
/// Campaign OVERHAUL S2 chunk 5: one cell id's borrowed retail render
/// membership. Reads <see cref="ShadowObjectRegistry.GetRetailPartEntriesInCell"/>
/// — already in retail CELLARRAY-then-part-array insertion order —
/// collapses adjacent entries down to their distinct owning entity ids
/// (an entity's own entries for one cell are always written as one
/// contiguous run: <c>PublishRetailPartEntries</c> removes then re-adds a
/// whole entity's rows atomically, never interleaving two entities'
/// rows), resolves each id to its current projected record through
/// <see cref="RenderSceneQuery.TryGetByLocalEntityId"/>, and keeps only
/// the records matching <paramref name="dynamic"/>'s static/dynamic
/// class and excluding building shells. Indoor vs. outdoor is entirely a
/// property of WHICH cellId the caller passes (an indoor cell id's low
/// word is <c>&gt;= 0x100</c>, an outdoor one is in <c>[1, 64]</c> —
/// retail's own two id spaces): the registry already published this
/// entity into every crossed cell under its own true id, so no
/// additional indoor/outdoor dispatch is needed here.
/// </summary>
private WalkFrameStaticRecords ResolveCellView(uint cellId, bool dynamic)
{
IReadOnlyList<RetailPartEntry> entries =
_shadows.GetRetailPartEntriesInCell(cellId);
if (entries.Count == 0)
return WalkFrameStaticRecords.Empty with { TupleLandblockId = _tupleLandblockId };
int written = 0;
uint previousEntityId = 0;
bool havePrevious = false;
for (int i = 0; i < entries.Count; i++)
{
uint entityId = entries[i].EntityId;
if (havePrevious && entityId == previousEntityId)
continue;
previousEntityId = entityId;
havePrevious = true;
if (!_scene.TryGetByLocalEntityId(entityId, out RenderProjectionRecord record))
{
// The transient streaming window (this type's own doc
// comment): the registry already flooded this entity into
// its retail CELLARRAY, but the presentation journal has
// not applied its projected record yet this frame. Retail
// draws nothing for an object not yet in a cell — no second
// fallback.
if (_unregisteredEntitiesThisFrame.Add(entityId))
UnregisteredRenderMembershipCount++;
continue;
}
if (record.EntityPayload.IsBuildingShell)
continue; // buildings draw at their own shell turn.
if (IsDynamicProjectionClass(record.ProjectionClass) != dynamic)
continue;
if (written == _cellViewScratch.Length)
{
var grown = new RenderProjectionRecord[_cellViewScratch.Length * 2];
Array.Copy(_cellViewScratch, grown, written);
_cellViewScratch = grown;
}
_cellViewScratch[written++] = record;
}
return written == 0
? WalkFrameStaticRecords.Empty with { TupleLandblockId = _tupleLandblockId }
: new WalkFrameStaticRecords(
AppendToArena(_cellViewScratch.AsSpan(0, written)), _tupleLandblockId);
}
/// <summary>The same static/dynamic split <c>ArchRenderScene</c>'s own
/// internal indexing uses: a live dynamic root or an equipped child is
/// "dynamic"; every other <see cref="RenderProjectionClass"/> (including
/// <see cref="RenderProjectionClass.ActiveAnimatedStatic"/>) is
/// "static".</summary>
private static bool IsDynamicProjectionClass(RenderProjectionClass projectionClass) =>
projectionClass is RenderProjectionClass.LiveDynamicRoot
or RenderProjectionClass.EquippedChild;
/// <summary> /// <summary>
/// Facility Hub discriminator for retail's cross-cell render-shadow path. /// Facility Hub discriminator for retail's cross-cell render-shadow path.
/// The scene query is currently keyed by authored parent cell, while retail /// The scene query is currently keyed by authored parent cell, while retail
@ -643,41 +424,6 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
} }
} }
public WalkFrameStaticRecords GetOutdoorStatics(uint cellId)
{
if (_outdoorMaterialized.TryGetValue(cellId, out WalkFrameStaticRecords cached))
return cached;
WalkFrameStaticRecords records =
_outdoorByCell.TryGetValue(cellId, out List<RenderProjectionRecord>? bucket)
&& bucket.Count > 0
? new WalkFrameStaticRecords(
AppendToArena(CollectionsMarshal.AsSpan(bucket)), _tupleLandblockId)
: WalkFrameStaticRecords.Empty with { TupleLandblockId = _tupleLandblockId };
_outdoorMaterialized[cellId] = records;
return records;
}
public WalkFrameStaticRecords GetOutdoorDynamics(uint cellId)
{
if (_outdoorDynamicsMaterialized.TryGetValue(
cellId,
out WalkFrameStaticRecords cached))
{
return cached;
}
WalkFrameStaticRecords records =
_outdoorDynamicsByCell.TryGetValue(
cellId,
out List<RenderProjectionRecord>? bucket)
&& bucket.Count > 0
? new WalkFrameStaticRecords(
AppendToArena(CollectionsMarshal.AsSpan(bucket)), _tupleLandblockId)
: WalkFrameStaticRecords.Empty with { TupleLandblockId = _tupleLandblockId };
_outdoorDynamicsMaterialized[cellId] = records;
return records;
}
public WalkFrameStaticRecords GetBuildingShellStatics(WalkBuilding building) public WalkFrameStaticRecords GetBuildingShellStatics(WalkBuilding building)
{ {
uint anchor = BuildingShellBucketCellId(building); uint anchor = BuildingShellBucketCellId(building);
@ -698,7 +444,7 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
/// <summary>Copies <paramref name="source"/> into <see cref="_arena"/> at /// <summary>Copies <paramref name="source"/> into <see cref="_arena"/> at
/// its current length, growing the arena first if needed (doubling, or /// its current length, growing the arena first if needed (doubling, or
/// exactly enough for an unusually large sweep — the same growth shape /// exactly enough for an unusually large sweep — the same growth shape
/// <see cref="_sweepScratch"/>/<see cref="_cellScratch"/> already use), /// <see cref="_sweepScratch"/>/<see cref="_cellViewScratch"/> already use),
/// and returns the segment the copy landed in. A prior frame's growth can /// and returns the segment the copy landed in. A prior frame's growth can
/// leave an earlier-returned segment pointing at a retired backing array /// leave an earlier-returned segment pointing at a retired backing array
/// — harmless, since that array's content stays valid and nothing reads /// — harmless, since that array's content stays valid and nothing reads

View file

@ -1084,6 +1084,34 @@ public sealed class LandblockPhysicsPublisher
} }
} }
// Campaign OVERHAUL S2 chunk 5 closeout: a DAT static with visual
// parts but NO collision geometry (decorative trim, wall panels,
// banners) is still a CPhysicsObj retail floods into its cells —
// calc_cross_cells_static (0x00515160) takes the find_bbox_cell_list
// route over the part array when there is no cylsphere, and
// add_shadows_to_cells (0x00514ae0) publishes AddPartsShadow rows
// for every part regardless of collision. Since chunk 5 the walk
// draws ONLY what the registry flooded (the parent-cell fallback is
// deleted), so such a static must register render-only here or it
// vanishes (G2 self-gate: the Facility Hub's purple-lit wall panels).
if (entityBspCount == 0 && entityCylinderCount == 0
&& partArray.Count > 0)
{
publication.StagingEngine.ShadowObjects.RegisterMultiPart(
entity.Id,
entity.Position,
entity.Rotation,
Array.Empty<ShadowShape>(),
0u,
EntityCollisionFlags.None,
publication.Origin.X,
publication.Origin.Y,
landblock.LandblockId,
seedCellId: entity.ParentCellId ?? 0u,
isStatic: true,
partArray: partArray);
}
if (entityBspCount > 0) if (entityBspCount > 0)
publication.BspOwnerCount++; publication.BspOwnerCount++;
if (entityCylinderCount > 0) if (entityCylinderCount > 0)

View file

@ -650,6 +650,8 @@ public static class LandblockPhysicsContentBuilder
entity.SourceGfxObjOrSetupId); entity.SourceGfxObjOrSetupId);
if (setup is null) if (setup is null)
{ {
RegisterRenderOnlyStatic(
engine, entity, partArray, landblock, origin);
noCollision++; noCollision++;
continue; continue;
} }
@ -710,6 +712,8 @@ public static class LandblockPhysicsContentBuilder
// LiveEntityCollisionBuilder's remarks for the retail anchor. // LiveEntityCollisionBuilder's remarks for the retail anchor.
if (setupShapes.Count == 0) if (setupShapes.Count == 0)
{ {
RegisterRenderOnlyStatic(
engine, entity, partArray, landblock, origin);
noCollision++; noCollision++;
continue; continue;
} }
@ -744,6 +748,41 @@ public static class LandblockPhysicsContentBuilder
noCollision); noCollision);
} }
/// <summary>
/// Campaign OVERHAUL S2 chunk 5 closeout: a DAT static with visual parts
/// but no collision geometry is still a <c>CPhysicsObj</c> retail floods
/// into its cells — <c>calc_cross_cells_static</c> (0x00515160) takes the
/// <c>find_bbox_cell_list</c> route over the part array when there is no
/// cylsphere, and <c>add_shadows_to_cells</c> (0x00514ae0) publishes
/// <c>AddPartsShadow</c> rows for every part regardless of collision.
/// The walk draws only what the registry flooded, so the static registers
/// render-only (empty collision shapes, whole part array); a static with
/// no visual part either stays unregistered.
/// </summary>
private static void RegisterRenderOnlyStatic(
PhysicsEngine engine,
WorldEntity entity,
IReadOnlyList<ShadowShape> partArray,
LoadedLandblock landblock,
Vector3 origin)
{
if (partArray.Count == 0)
return;
engine.ShadowObjects.RegisterMultiPart(
entity.Id,
entity.Position,
entity.Rotation,
Array.Empty<ShadowShape>(),
0u,
EntityCollisionFlags.None,
worldOffsetX: origin.X,
worldOffsetY: origin.Y,
landblockId: landblock.LandblockId,
seedCellId: entity.ParentCellId ?? 0u,
isStatic: true,
partArray: partArray);
}
private static T Require<T>( private static T Require<T>(
PreparedCollisionReadResult<T> result, PreparedCollisionReadResult<T> result,
string kind, string kind,

View file

@ -506,6 +506,20 @@ public sealed class ShadowObjectRegistry
RetailCellArrayRoute route, RetailCellArrayRoute route,
IReadOnlyList<ShadowShape> partArray) IReadOnlyList<ShadowShape> partArray)
{ {
// Campaign OVERHAUL S2 review fix (arch F3): retail never floods an
// attached child — add_shadows_to_cells (0x00514ae0) passes the
// ROOT's CELLARRAY down through `children`. A re-registration or move
// of an entity that is currently attached (an equipped item's
// appearance update through ReplaceMultiPartPayload/RegisterMultiPart)
// only refreshes the part array it inherits the root's cells with.
if (_childParent.ContainsKey(entityId))
{
if (partArray.Count != 0)
_childPartArrays[entityId] = partArray;
_retailCellArrayRoutes[entityId] = route;
PublishChildEntries(entityId);
return;
}
if (_retailCellArrays.TryGetValue(entityId, out List<uint>? previousCells)) if (_retailCellArrays.TryGetValue(entityId, out List<uint>? previousCells))
{ {
RemoveRetailPartEntriesFromCells(entityId, previousCells); RemoveRetailPartEntriesFromCells(entityId, previousCells);
@ -604,13 +618,71 @@ public sealed class ShadowObjectRegistry
cellIds[i], cellIds[i],
out List<RetailPartEntry>? entries)) out List<RetailPartEntry>? entries))
{ {
entries.RemoveAll(e => e.EntityId == entityId); RemoveOwnerPartRows(entries, entityId);
if (entries.Count == 0) if (entries.Count == 0)
_retailPartEntriesByCell.Remove(cellIds[i]); _retailPartEntriesByCell.Remove(cellIds[i]);
} }
} }
} }
/// <summary>The <see cref="RemoveOwnerRows"/> twin for the retail part
/// rows: a reverse index loop, no closure — this runs per cell on every
/// accepted move (arch review F7).</summary>
private static void RemoveOwnerPartRows(
List<RetailPartEntry> entries,
uint entityId)
{
for (int index = entries.Count - 1; index >= 0; index--)
{
if (entries[index].EntityId == entityId)
entries.RemoveAt(index);
}
}
private static ShadowEntry[] CollectOwnerRows(
List<ShadowEntry> entries,
uint entityId)
{
int count = 0;
for (int index = 0; index < entries.Count; index++)
{
if (entries[index].EntityId == entityId)
count++;
}
if (count == 0)
return Array.Empty<ShadowEntry>();
var rows = new ShadowEntry[count];
int written = 0;
for (int index = 0; index < entries.Count; index++)
{
if (entries[index].EntityId == entityId)
rows[written++] = entries[index];
}
return rows;
}
private static RetailPartEntry[] CollectOwnerPartRows(
List<RetailPartEntry> entries,
uint entityId)
{
int count = 0;
for (int index = 0; index < entries.Count; index++)
{
if (entries[index].EntityId == entityId)
count++;
}
if (count == 0)
return Array.Empty<RetailPartEntry>();
var rows = new RetailPartEntry[count];
int written = 0;
for (int index = 0; index < entries.Count; index++)
{
if (entries[index].EntityId == entityId)
rows[written++] = entries[index];
}
return rows;
}
/// <summary> /// <summary>
/// Publishes retail's <c>CPartArray::AddPartsShadow</c> (0x00517e40) rows /// Publishes retail's <c>CPartArray::AddPartsShadow</c> (0x00517e40) rows
/// for one entity: for every cell in <paramref name="orderedCells"/>, in /// for one entity: for every cell in <paramref name="orderedCells"/>, in
@ -742,6 +814,12 @@ public sealed class ShadowObjectRegistry
_childPartArrays[childEntityId] = childPartArray; _childPartArrays[childEntityId] = childPartArray;
PublishChildEntries(childEntityId); PublishChildEntries(childEntityId);
// Campaign OVERHAUL S2 review fix (arch F2): an attach mutates shared
// per-cell rows, so it must invalidate any prepared SetPosition commit
// whose captured cell lists predate it (IsPreparedSetPositionCurrent
// keys on the mutation revision) — otherwise that commit's wholesale
// list install silently drops the child's rows.
AdvanceMutationRevision();
return true; return true;
} }
@ -786,6 +864,7 @@ public sealed class ShadowObjectRegistry
if (siblings.Count == 0) if (siblings.Count == 0)
_parentChildren.Remove(parentId); _parentChildren.Remove(parentId);
} }
AdvanceMutationRevision(); // see AttachChild (arch F2)
return true; return true;
} }
@ -1041,7 +1120,32 @@ public sealed class ShadowObjectRegistry
bool publishMutation = true, bool publishMutation = true,
IReadOnlyList<ShadowShape>? partArray = null) IReadOnlyList<ShadowShape>? partArray = null)
{ {
if (shapes.Count == 0) { Deregister(entityId); return; } if (shapes.Count == 0)
{
// Campaign OVERHAUL S2 chunk 5 (item B): a live entity whose
// collision dispatch produced NO shapes (LiveEntityCollisionBuilder.
// Build's Shapes==[]) but whose Setup still has visual parts — the
// retail short-lived spell/visual effect-object case — is NOT a
// deregistration. Contract B's render membership
// (CPartArray::AddPartsShadow) does not require a successful
// collision dispatch; only the collision-side shadow_object_list
// does. Route it to the render-only registration path instead.
// A caller with no part array either (every pre-chunk-5 test
// fixture, and any genuinely shapeless entity) keeps the exact
// prior behavior: deregister.
if (partArray is { Count: > 0 })
{
RegisterRenderOnly(
entityId, entityWorldPos, entityWorldRot, state, flags,
worldOffsetX, worldOffsetY, landblockId, seedCellId,
isStatic, publishMutation, partArray);
}
else
{
Deregister(entityId);
}
return;
}
// Flood FIRST — keep-when-empty, see Register. // Flood FIRST — keep-when-empty, see Register.
uint seed = seedCellId != 0u uint seed = seedCellId != 0u
@ -1157,6 +1261,79 @@ public sealed class ShadowObjectRegistry
} }
} }
/// <summary>
/// Campaign OVERHAUL S2 chunk 5 (item B): registers a live entity that
/// has NO collision shapes at all but DOES have a visual part array — the
/// retail short-lived spell/visual effect-object case
/// (<c>LiveEntityCollisionBuilder.Build</c> returns
/// <c>Shapes = []</c>, <c>RenderParts != []</c>). Retail's
/// <c>calc_cross_cells_static</c> dispatch (Contract A) never
/// special-cases "no collision shapes": the cylsphere-vs-bbox TEST reads
/// the collision shapes (none here, so the test is always false) and the
/// flood unconditionally falls through to the bbox route over the WHOLE
/// part array (<see cref="ComputeContractACellArray"/> with an empty
/// <c>collisionShapes</c> list). The entity therefore gets an exact
/// retail CELLARRAY and <see cref="RetailPartEntry"/> rows exactly like a
/// colliding entity, but never a <see cref="ShadowEntry"/> collision row
/// anywhere — Contract B's <c>shadow_object_list</c> receives one entry
/// per CELLARRAY cell only when the object HAS a part to shadow there;
/// this object contributes to <c>shadow_part_list</c> only.
/// <para>
/// <see cref="_entityShapes"/> is retained as an EMPTY (not absent) list
/// so every existing multi-part-dispatch site
/// (<see cref="UpdatePosition"/>, <see cref="ReplacePositionRows"/>,
/// <see cref="RefloodOwnerForLandblock"/>) takes the "multi-part, zero
/// shapes" branch on a later move/reflood instead of falling back to the
/// single-shape <see cref="Register"/> path or synthesizing a bogus
/// zero-radius shadow entry.
/// </para>
/// </summary>
private void RegisterRenderOnly(
uint entityId,
Vector3 entityWorldPos,
Quaternion entityWorldRot,
uint state,
EntityCollisionFlags flags,
float worldOffsetX,
float worldOffsetY,
uint landblockId,
uint seedCellId,
bool isStatic,
bool publishMutation,
IReadOnlyList<ShadowShape> partArray)
{
// Flood FIRST — keep-when-empty, see Register.
uint seed = seedCellId != 0u
? seedCellId
: DeriveOutdoorSeed(entityWorldPos, worldOffsetX, worldOffsetY, landblockId);
if (seed == 0u) return;
(IReadOnlyList<uint> cellSet, RetailCellArrayRoute retailRoute) =
ComputeContractACellArray(
seed,
entityWorldPos,
entityWorldRot,
state,
collisionShapes: Array.Empty<ShadowShape>(),
partArray,
isStatic);
if (cellSet.Count == 0) return; // keep-when-empty (pc:283540).
DeregisterCore(entityId, publishMutation: false);
_entityShapes[entityId] = Array.Empty<ShadowShape>();
_entityReg[entityId] = new RegistrationRecord(
seed, entityWorldPos, entityWorldRot, state, flags, isStatic,
IsMultiPart: true, GfxObjId: 0u, Radius: 0f,
CollisionType: ShadowCollisionType.BSP, CylHeight: 0f, Scale: 1f);
if (publishMutation)
BumpOwnerVersion(entityId);
else
RefreshOwnerPrefixIndex(entityId);
_entityRetailPartArrays[entityId] = partArray;
PublishRetailCellArray(entityId, cellSet, retailRoute, partArray);
}
/// <summary> /// <summary>
/// Replaces an existing live PartArray collision payload in its current /// Replaces an existing live PartArray collision payload in its current
/// shadow-cell membership. Retail <c>CPartArray::SetPart</c> changes the /// shadow-cell membership. Retail <c>CPartArray::SetPart</c> changes the
@ -1183,7 +1360,11 @@ public sealed class ShadowObjectRegistry
if (!_entityReg.TryGetValue(entityId, out RegistrationRecord? prior) if (!_entityReg.TryGetValue(entityId, out RegistrationRecord? prior)
|| !prior.IsMultiPart) || !prior.IsMultiPart)
{ {
if (shapes.Count == 0) // Campaign OVERHAUL S2 chunk 5 (item B): a not-yet-registered
// entity with no collision shapes but a real part array still
// needs registering (render-only) — RegisterMultiPart's own
// shapes.Count==0 branch now handles that dispatch.
if (shapes.Count == 0 && (partArray is null || partArray.Count == 0))
return; return;
RegisterMultiPart( RegisterMultiPart(
entityId, entityId,
@ -1217,7 +1398,10 @@ public sealed class ShadowObjectRegistry
// this replaces the retained part array WITHOUT re-flooding. It // this replaces the retained part array WITHOUT re-flooding. It
// reuses the CURRENT retail CELLARRAY (if any) and only rewrites // reuses the CURRENT retail CELLARRAY (if any) and only rewrites
// which parts occupy it, mirroring the collision payload swap below. // which parts occupy it, mirroring the collision payload swap below.
if (partArray is not null) // Arch review F9: a non-null but EMPTY part array is "no part to
// shadow" — treated exactly like the null case rather than leaving a
// cell array with no drawable rows behind.
if (partArray is { Count: > 0 })
{ {
_entityRetailPartArrays[entityId] = partArray; _entityRetailPartArrays[entityId] = partArray;
if (_retailCellArrays.TryGetValue(entityId, out List<uint>? retailCells) if (_retailCellArrays.TryGetValue(entityId, out List<uint>? retailCells)
@ -2270,6 +2454,37 @@ public sealed class ShadowObjectRegistry
return; return;
} }
// Campaign OVERHAUL S2 review fix (retail F3): a RENDER-ONLY owner
// (part array, no collision shapes, hence never any retained
// collision cells) still moves in retail — add_shadows_to_cells
// (0x00514ae0) adds the CShadowObj unconditionally (pc:282856) and
// gates only AddPartsShadow on part_array != 0, so a shapeless
// visual object keeps a cell array it travels with. Its transition
// carries no sphere, so that array is its destination cell alone
// (num_cells == 1 → AddPartsShadow without clip planes). AD-117
// records the single-cell reading until a cdb trace pins the
// zero-sphere transition's exact cell list.
if (!_entityToCells.ContainsKey(entityId)
&& !_suspendedEntityCells.ContainsKey(entityId)
&& seedCellId != 0u
&& _entityRetailPartArrays.TryGetValue(
entityId,
out IReadOnlyList<ShadowShape>? renderPartArray)
&& renderPartArray.Count != 0)
{
_suspendedEntities.Remove(entityId);
_entityReg[entityId] = registration with
{
SeedCellId = seedCellId,
EntityWorldPos = worldPosition,
EntityWorldRot = worldRotation,
};
_singleCellScratch[0] = seedCellId;
PublishRetailProductFromExactCells(entityId, _singleCellScratch);
BumpOwnerVersion(entityId);
return;
}
// Campaign OVERHAUL S2 chunk 4: retail's keep-when-empty gate // Campaign OVERHAUL S2 chunk 4: retail's keep-when-empty gate
// (pc:283540) reached its terminal case — no retained collision // (pc:283540) reached its terminal case — no retained collision
// cells at all to republish from — so BOTH products stay exactly as // cells at all to republish from — so BOTH products stay exactly as
@ -2285,6 +2500,8 @@ public sealed class ShadowObjectRegistry
BumpOwnerVersion(entityId); BumpOwnerVersion(entityId);
} }
private readonly uint[] _singleCellScratch = new uint[1];
private void ReplacePositionRows( private void ReplacePositionRows(
uint entityId, uint entityId,
RegistrationRecord registration, RegistrationRecord registration,
@ -2326,12 +2543,12 @@ public sealed class ShadowObjectRegistry
exactCells.Add(cellId); exactCells.Add(cellId);
} }
if (registration.IsMultiPart IReadOnlyList<ShadowShape>? shapes = null;
&& _entityShapes.TryGetValue( bool isMultiPartDispatch = registration.IsMultiPart
entityId, && _entityShapes.TryGetValue(entityId, out shapes);
out IReadOnlyList<ShadowShape>? shapes)) if (isMultiPartDispatch)
{ {
foreach (ShadowShape shape in shapes) foreach (ShadowShape shape in shapes!)
{ {
Vector3 partWorldPosition = worldPosition Vector3 partWorldPosition = worldPosition
+ Vector3.Transform(shape.LocalPosition, worldRotation); + Vector3.Transform(shape.LocalPosition, worldRotation);
@ -2371,7 +2588,15 @@ public sealed class ShadowObjectRegistry
AddEntryToCell(entry, exactCells[index]); AddEntryToCell(entry, exactCells[index]);
} }
if (exactCells.Count == 0) // Campaign OVERHAUL S2 chunk 5 (item B): a render-only multi-part
// entity (registration.IsMultiPart with an EMPTY retained shapes
// list — see RegisterRenderOnly) writes NO ShadowEntry rows above.
// _entityToCells must stay absent for it too, or a consumer reading
// it (GetOwnerCells, DeregisterCore's cleanup walk) would see cell
// ids that carry no actual collision row — a membership claim this
// registry never backs with a shadow_object_list entry.
bool wroteCollisionEntries = !isMultiPartDispatch || shapes!.Count != 0;
if (exactCells.Count == 0 || !wroteCollisionEntries)
_entityToCells.Remove(entityId); _entityToCells.Remove(entityId);
else else
_entityToCells[entityId] = exactCells; _entityToCells[entityId] = exactCells;
@ -2414,11 +2639,26 @@ public sealed class ShadowObjectRegistry
foreach (uint cellId in cellIds) foreach (uint cellId in cellIds)
{ {
if (_cells.TryGetValue(cellId, out var list)) if (_cells.TryGetValue(cellId, out var list))
list.RemoveAll(entry => entry.EntityId == entityId); RemoveOwnerRows(list, entityId);
} }
_entityToCells.Remove(entityId); _entityToCells.Remove(entityId);
} }
// Campaign OVERHAUL S2 review fix (arch F1 / retail F2): retail's
// remove_shadows_from_cells (0x00511230) is ONE transaction over both
// products — per shadow cell it calls CObjCell::remove_shadow_object
// AND CPartArray::RemoveParts, then recurses through children. A
// suspended object therefore has no render membership either; the
// retained part array and route survive so the un-suspending move
// (ReplacePositionRows → PublishRetailProductFromExactCells)
// republishes from the transition's cells.
if (_retailCellArrays.TryGetValue(entityId, out List<uint>? retailCells))
{
RemoveRetailPartEntriesFromCells(entityId, retailCells);
_retailCellArrays.Remove(entityId);
RepublishAttachedChildren(entityId);
}
_suspendedEntities.Add(entityId); _suspendedEntities.Add(entityId);
BumpOwnerVersion(entityId); BumpOwnerVersion(entityId);
return true; return true;
@ -2502,6 +2742,19 @@ public sealed class ShadowObjectRegistry
_withdrawnPrefixesByOwner.TryGetValue( _withdrawnPrefixesByOwner.TryGetValue(
entityId, entityId,
out var withdrawnBeforeReflood); out var withdrawnBeforeReflood);
// Campaign OVERHAUL S2 chunk 5 closeout: a reflood is retail's
// recalc_cross_cells over the SAME CPartArray — the render product
// (retail cell array + AddPartsShadow rows) is recomputed from the
// retained part array exactly like the movement path above does.
// Without this the walk (which reads ONLY GetRetailPartEntriesInCell
// since chunk 5) lost every reflooded owner: landblock replacement
// commits (PhysicsEngine.ApplyCommittedOwnerReplacement) and the
// Content builder's post-publication reflood both come through here.
_entityRetailPartArrays.TryGetValue(
entityId,
out IReadOnlyList<ShadowShape>? retainedPartArray);
if (reg.IsMultiPart if (reg.IsMultiPart
&& _entityShapes.TryGetValue(entityId, out var shapes)) && _entityShapes.TryGetValue(entityId, out var shapes))
{ {
@ -2517,7 +2770,8 @@ public sealed class ShadowObjectRegistry
lbPrefix, lbPrefix,
reg.SeedCellId, reg.SeedCellId,
reg.IsStatic, reg.IsStatic,
publishMutation: false); publishMutation: false,
partArray: retainedPartArray);
} }
else else
{ {
@ -2537,7 +2791,8 @@ public sealed class ShadowObjectRegistry
reg.Flags, reg.Flags,
reg.SeedCellId, reg.SeedCellId,
reg.IsStatic, reg.IsStatic,
publishMutation: false); publishMutation: false,
partArray: retainedPartArray);
} }
// Register is also the authoritative movement/replacement API and // Register is also the authoritative movement/replacement API and
@ -2870,10 +3125,69 @@ public sealed class ShadowObjectRegistry
_withdrawnPrefixesByOwner.Remove(eid); _withdrawnPrefixesByOwner.Remove(eid);
} }
} }
// Campaign OVERHAUL S2 review fix (arch F4 / retail F4): the retail
// render product ends with the landblock too — remove_shadows_from_
// cells (0x00511230) never removes the CShadowObj without the
// AddPartsShadow rows. Every part row in the prefix's cells goes;
// each owner's cell array loses those cells (a dynamic owner keeps
// its part array for the reload reflood, a static owner ends here —
// including a render-only static, which has no collision cells and
// so was invisible to the loops above).
RemoveRetailProductForPrefix(lbPrefix, touchedOwners);
foreach (uint entityId in touchedOwners) foreach (uint entityId in touchedOwners)
BumpOwnerVersion(entityId); BumpOwnerVersion(entityId);
} }
private readonly List<uint> _prefixRemovalScratch = new();
private void RemoveRetailProductForPrefix(
uint lbPrefix,
HashSet<uint> touchedOwners)
{
_prefixRemovalScratch.Clear();
foreach (uint cellId in _retailPartEntriesByCell.Keys)
{
if ((cellId & 0xFFFF0000u) == lbPrefix)
_prefixRemovalScratch.Add(cellId);
}
for (int i = 0; i < _prefixRemovalScratch.Count; i++)
_retailPartEntriesByCell.Remove(_prefixRemovalScratch[i]);
_prefixRemovalScratch.Clear();
foreach (var (ownerId, cells) in _retailCellArrays)
{
for (int i = cells.Count - 1; i >= 0; i--)
{
if ((cells[i] & 0xFFFF0000u) == lbPrefix)
{
cells.RemoveAt(i);
touchedOwners.Add(ownerId);
}
}
if (cells.Count == 0)
_prefixRemovalScratch.Add(ownerId);
}
for (int i = 0; i < _prefixRemovalScratch.Count; i++)
{
uint ownerId = _prefixRemovalScratch[i];
_retailCellArrays.Remove(ownerId);
bool endsWithLandblock =
!_entityReg.TryGetValue(ownerId, out RegistrationRecord? registration)
|| registration.IsStatic;
if (!endsWithLandblock)
continue;
_retailCellArrayRoutes.Remove(ownerId);
_entityRetailPartArrays.Remove(ownerId);
// A render-only static never had collision cells, so the static
// retirement loop above could not reach its registration.
_entityShapes.Remove(ownerId);
_entityReg.Remove(ownerId);
_suspendedEntities.Remove(ownerId);
_suspendedEntityCells.Remove(ownerId);
_withdrawnPrefixesByOwner.Remove(ownerId);
}
}
/// <summary> /// <summary>
/// Retires one logical owner's rows from a streamed-out prefix. This is /// Retires one logical owner's rows from a streamed-out prefix. This is
/// the owner-granular form used by the collision-generation retirement /// the owner-granular form used by the collision-generation retirement
@ -2895,10 +3209,39 @@ public sealed class ShadowObjectRegistry
AdvanceMutationRevision(); AdvanceMutationRevision();
return; return;
} }
if (!_entityToCells.TryGetValue(entityId, out List<uint>? cells))
return;
bool touched = false; bool touched = false;
// Campaign OVERHAUL S2 review fix (arch F4 / retail F4): the owner's
// retail part rows leave the retired prefix's cells together with
// its collision rows (remove_shadows_from_cells 0x00511230 removes
// both per cell); the part array stays for the reload reflood.
if (_retailCellArrays.TryGetValue(entityId, out List<uint>? retailCells))
{
for (int index = retailCells.Count - 1; index >= 0; index--)
{
uint cellId = retailCells[index];
if ((cellId & 0xFFFF0000u) != prefix)
continue;
touched = true;
retailCells.RemoveAt(index);
if (_retailPartEntriesByCell.TryGetValue(
cellId,
out List<RetailPartEntry>? partRows))
{
RemoveOwnerPartRows(partRows, entityId);
if (partRows.Count == 0)
_retailPartEntriesByCell.Remove(cellId);
}
}
if (retailCells.Count == 0)
_retailCellArrays.Remove(entityId);
}
if (!_entityToCells.TryGetValue(entityId, out List<uint>? cells))
{
if (touched)
BumpOwnerVersion(entityId);
return;
}
for (int index = cells.Count - 1; index >= 0; index--) for (int index = cells.Count - 1; index >= 0; index--)
{ {
uint cellId = cells[index]; uint cellId = cells[index];
@ -3311,8 +3654,7 @@ public sealed class ShadowObjectRegistry
{ {
rows.Add(new PreparedShadowCellRows( rows.Add(new PreparedShadowCellRows(
cellId, cellId,
entries.Where(entry => entry.EntityId == entityId) CollectOwnerRows(entries, entityId)));
.ToArray()));
} }
} }
} }
@ -3341,8 +3683,7 @@ public sealed class ShadowObjectRegistry
{ {
retailRows.Add(new PreparedShadowRetailPartRows( retailRows.Add(new PreparedShadowRetailPartRows(
cellId, cellId,
entries.Where(entry => entry.EntityId == entityId) CollectOwnerPartRows(entries, entityId)));
.ToArray()));
} }
} }
} }

View file

@ -220,19 +220,15 @@ internal sealed class LiveEntityCollisionBuilder
effectivePartGfxObjIds: effectivePartGfxObjIds, effectivePartGfxObjIds: effectivePartGfxObjIds,
physicsBspBounds: _physicsBspBounds); physicsBspBounds: _physicsBspBounds);
if (shapes.Count == 0 && !retainEmptyPayload) // Campaign OVERHAUL S2 chunk 5 (item B): retail's WHOLE visual part
return null; // array — every Setup part, colliding or not — computed BEFORE the
// empty-shapes gate below. A live entity whose Setup carries visual
EntityCollisionFlags flags = EntityCollisionFlags.HasWeenie; // parts but no collision geometry at all (a short-lived spell/visual
if (spawn.ObjectDescriptionFlags is { } descriptionFlags) // effect object) must still register for render: Contract B's
flags |= EntityCollisionFlagsExt.FromPwdBitfield(descriptionFlags); // CPartArray::AddPartsShadow membership is not gated on a successful
if (spawn.ItemType == (uint)ItemType.Creature) // collision-shape dispatch, only on a non-null part array. Distinct
flags |= EntityCollisionFlags.IsCreature; // from `shapes`, which stays the BSP-exclusive COLLISION dispatch
// (AP-152).
// Campaign OVERHAUL S2 chunk 1b: retail's WHOLE visual part array —
// every Setup part, colliding or not — beside the BSP-exclusive
// `shapes` collision dispatch above. A side product only; nothing
// consumes it yet.
IReadOnlyList<ShadowShape> renderParts = ShadowShapeBuilder.FromSetupRenderParts( IReadOnlyList<ShadowShape> renderParts = ShadowShapeBuilder.FromSetupRenderParts(
setup, setup,
scale, scale,
@ -241,6 +237,21 @@ internal sealed class LiveEntityCollisionBuilder
_getGfxObj, _getGfxObj,
_getVisualBounds); _getVisualBounds);
// Campaign OVERHAUL S2 chunk 5: a Setup with NEITHER a collision
// shape NOR a visual part yields no registration at all — retail
// synthesizes nothing for a truly shapeless object (see this
// method's class remarks). `retainEmptyPayload` (ObjDesc updates
// that must be able to clear a prior collision payload down to
// nothing) still forces a registration through even here.
if (shapes.Count == 0 && renderParts.Count == 0 && !retainEmptyPayload)
return null;
EntityCollisionFlags flags = EntityCollisionFlags.HasWeenie;
if (spawn.ObjectDescriptionFlags is { } descriptionFlags)
flags |= EntityCollisionFlagsExt.FromPwdBitfield(descriptionFlags);
if (spawn.ItemType == (uint)ItemType.Creature)
flags |= EntityCollisionFlags.IsCreature;
return new LiveEntityCollisionRegistration( return new LiveEntityCollisionRegistration(
entity.Id, entity.Id,
entity.SourceGfxObjOrSetupId, entity.SourceGfxObjOrSetupId,

View file

@ -155,6 +155,78 @@ public sealed class LiveEntityCollisionBuilderTests
Assert.Equal(part, shape.GfxObjId); Assert.Equal(part, shape.GfxObjId);
} }
/// <summary>
/// Campaign OVERHAUL S2 chunk 5 (item B): a short-lived spell/visual
/// effect object's Setup can carry a visual part with no physics BSP and
/// no CylSpheres/Spheres — retail's <c>FindObjCollisions</c> synthesizes
/// no COLLISION shape for such a part (this file's own
/// <see cref="ShapelessSetupWithRadius_ProducesNoRegistration"/> remark),
/// but the part is still a real visual part
/// <c>CPartArray::AddPartsShadow</c> must register for render (Contract
/// B). <see cref="LiveEntityCollisionBuilder.Build"/> must not collapse
/// this case to "no registration at all" just because the COLLISION
/// dispatch came up empty — it must still return a registration whose
/// <c>RenderParts</c> carries the part, and
/// <see cref="LiveEntityCollisionBuilder.Register"/> (the exact call
/// <c>DatLiveEntityProjectionMaterializer</c> makes) must register it
/// render-only: a real retail CELLARRAY with per-cell
/// <c>RetailPartEntry</c> rows, but NO collision row anywhere.
/// </summary>
[Fact]
public void Build_PartWithNoCollisionButResolvableVisualBounds_RegistersRenderOnly()
{
const uint part = 0x0100BEEFu;
var setup = new Setup();
setup.Parts.Add(part);
WorldSession.EntitySpawn spawn = Spawn(scale: 1f);
var record = LiveEntityTestFixture.CreateExactProjectionRecord(spawn);
WorldEntity entity = Entity();
record.WorldEntity = entity;
var builder = new LiveEntityCollisionBuilder(
physicsBspBounds: _ => null,
PoseResolver(),
getGfxObj: _ => null,
getVisualBounds: id => id == part
? new GfxObjVisualBounds
{
Min = new Vector3(-1f, -1f, -1f),
Max = new Vector3(1f, 1f, 1f),
Center = Vector3.Zero,
Radius = 1.5f,
HalfExtents = new Vector3(1f, 1f, 1f),
}
: null);
LiveEntityCollisionRegistration registration = Assert.IsType<LiveEntityCollisionRegistration>(
builder.Build(
entity,
setup,
[part],
spawn,
record.ServerGuid,
record.Generation,
record.WorldEntity!,
record.FinalPhysicsState,
Vector3.Zero));
Assert.Empty(registration.Shapes);
ShadowShape renderPart = Assert.Single(registration.RenderParts);
Assert.Equal(part, renderPart.GfxObjId);
var registry = new ShadowObjectRegistry();
LiveEntityCollisionBuilder.Register(registry, registration);
Assert.True(
registry.TryGetRetailCellArray(entity.Id, out IReadOnlyList<uint> cells));
Assert.NotEmpty(cells);
Assert.Empty(registry.GetOwnerCells(entity.Id));
var entries = registry.GetRetailPartEntriesInCell(cells[0])
.Where(e => e.EntityId == entity.Id)
.ToList();
Assert.Single(entries);
Assert.Equal(part, entries[0].GfxObjId);
}
/// <summary> /// <summary>
/// AP-152. Every other fixture in this file is primitive-only or BSP-only, /// AP-152. Every other fixture in this file is primitive-only or BSP-only,
/// so nothing at the App layer used to exercise the CylSphere+BSP /// so nothing at the App layer used to exercise the CylSphere+BSP

View file

@ -520,13 +520,6 @@ public sealed class ArchRenderSceneTests
LightCandidate: 1, LightCandidate: 1,
Dirty: 6), Dirty: 6),
query.IndexCounts); query.IndexCounts);
Assert.Equal(1, query.GetCellStaticCount(indoorCell));
Assert.Equal(1, query.GetCellDynamicCount(indoorCell));
var cellRecords = new RenderProjectionRecord[1];
Assert.Equal(1, query.CopyCellStaticsTo(indoorCell, cellRecords));
Assert.Equal(indoorStatic.Id, cellRecords[0].Id);
Assert.Equal(1, query.CopyCellDynamicsTo(indoorCell, cellRecords));
Assert.Equal(cellDynamic.Id, cellRecords[0].Id);
Assert.True(scene.Memory.EstimatedIndexBytes > 0); Assert.True(scene.Memory.EstimatedIndexBytes > 0);
} }
@ -580,7 +573,6 @@ public sealed class ArchRenderSceneTests
Assert.Equal(1, updated.IndexCounts.Translucent); Assert.Equal(1, updated.IndexCounts.Translucent);
Assert.Equal(1, updated.IndexCounts.LightCandidate); Assert.Equal(1, updated.IndexCounts.LightCandidate);
Assert.Equal(1, updated.IndexCounts.Dirty); Assert.Equal(1, updated.IndexCounts.Dirty);
Assert.Equal(1, updated.GetCellDynamicCount(indoorCell));
RenderProjectionRecord replacement = rebucketed with RenderProjectionRecord replacement = rebucketed with
{ {
@ -592,7 +584,6 @@ public sealed class ArchRenderSceneTests
RenderSceneQuery replaced = scene.OpenQuery(); RenderSceneQuery replaced = scene.OpenQuery();
Assert.Equal(0, replaced.IndexCounts.Dynamic); Assert.Equal(0, replaced.IndexCounts.Dynamic);
Assert.Equal(1, replaced.IndexCounts.IndoorCellStatic); Assert.Equal(1, replaced.IndexCounts.IndoorCellStatic);
Assert.Equal(1, replaced.GetCellStaticCount(indoorCell));
scene.Apply( scene.Apply(
[ [
@ -1085,6 +1076,92 @@ public sealed class ArchRenderSceneTests
Assert.Equal(changed.LatestRevision, query.DirectionalShadowTransformRevision); Assert.Equal(changed.LatestRevision, query.DirectionalShadowTransformRevision);
} }
// Campaign OVERHAUL S2 review fix round (F5/F6): the LocalEntityId index.
private static RenderProjectionRecord WithLocalEntityId(
RenderProjectionRecord record,
uint localEntityId) =>
record with
{
Source = new RenderSourceMetadata(
LocalEntityId: localEntityId,
ServerGuid: 0,
SourceId: 1,
ParentCellId: record.Source.ParentCellId,
EffectCellId: 0,
BuildingShellAnchorCellId: 0,
TransformFingerprint: new RenderSceneHash128(1, 1),
GeometryFingerprint: new RenderSceneHash128(2, 2),
AppearanceFingerprint: new RenderSceneHash128(3, 3)),
};
[Fact]
public void TryGetByLocalEntityId_IgnoresEnvCellShellRecords()
{
// An EnvCell shell carries its CELL id in the entity-id slot; a
// dungeon cell id such as 0x8A020100 is numerically a procedural
// scenery entity id. The shell must never shadow the real entity.
const uint aliasedId = 0x8A020100u;
RenderSceneGeneration generation = Generation(31);
using var scene = new ArchRenderScene(generation);
RenderProjectionRecord shell = WithLocalEntityId(
Record(310, 1, RenderProjectionClass.IndoorCellStatic), aliasedId);
RenderProjectionRecord scenery = WithLocalEntityId(
Record(311, 1, RenderProjectionClass.OutdoorStatic), aliasedId);
scene.Apply(
[
RenderProjectionDelta.Register(generation, 1, shell),
RenderProjectionDelta.Register(generation, 2, scenery),
]);
RenderSceneQuery query = scene.OpenQuery();
Assert.True(query.TryGetByLocalEntityId(aliasedId, out RenderProjectionRecord found));
Assert.Equal(scenery.Id, found.Id);
// Unregistering the shell never disturbs the entity's mapping.
scene.Apply(
[
RenderProjectionDelta.Unregister(
generation,
3,
shell.Id,
shell.OwnerIncarnation),
]);
query = scene.OpenQuery();
Assert.True(query.TryGetByLocalEntityId(aliasedId, out found));
Assert.Equal(scenery.Id, found.Id);
}
[Fact]
public void Update_ThatRebindsTheLocalEntityId_MovesTheIndex()
{
RenderSceneGeneration generation = Generation(32);
using var scene = new ArchRenderScene(generation);
RenderProjectionRecord original = WithLocalEntityId(
Record(320, 1, RenderProjectionClass.LiveDynamicRoot), 19);
scene.Apply([RenderProjectionDelta.Register(generation, 1, original)]);
Assert.True(scene.OpenQuery().TryGetByLocalEntityId(19, out _));
// Same class, cell, flags, mesh count and sort key — only the id
// changes; the index must follow it (IndexMembershipEquals compares
// the id since the S2 review fix).
RenderProjectionRecord rebound = WithLocalEntityId(original, 20);
scene.Apply(
[
RenderProjectionDelta.Update(
RenderProjectionDeltaKind.UpdateFlags,
generation,
2,
rebound),
]);
RenderSceneQuery query = scene.OpenQuery();
Assert.False(query.TryGetByLocalEntityId(19, out _));
Assert.True(query.TryGetByLocalEntityId(20, out RenderProjectionRecord found));
Assert.Equal(original.Id, found.Id);
}
private static RenderProjectionRecord Record( private static RenderProjectionRecord Record(
ulong id, ulong id,
ulong incarnation, ulong incarnation,

View file

@ -103,7 +103,6 @@ public sealed class DirectionalShadowCasterFrameTests
Assert.Equal( Assert.Equal(
[RenderSceneIndex.OutdoorStatic, RenderSceneIndex.OutdoorDynamic], [RenderSceneIndex.OutdoorStatic, RenderSceneIndex.OutdoorDynamic],
source.CopiedIndices); source.CopiedIndices);
Assert.Equal(0, source.CellQueries);
Assert.Equal(2, frame.Stats.IndexCopies); Assert.Equal(2, frame.Stats.IndexCopies);
} }
@ -635,7 +634,6 @@ public sealed class DirectionalShadowCasterFrameTests
public int IndexCountReads { get; private set; } public int IndexCountReads { get; private set; }
public int IndexCopies { get; private set; } public int IndexCopies { get; private set; }
public int CellQueries { get; private set; }
public int ProjectionReads { get; private set; } public int ProjectionReads { get; private set; }
public int BatchedProjectionCopies { get; private set; } public int BatchedProjectionCopies { get; private set; }
public ulong TopologyRevision { get; private set; } = 1; public ulong TopologyRevision { get; private set; } = 1;
@ -731,6 +729,32 @@ public sealed class DirectionalShadowCasterFrameTests
return false; return false;
} }
public bool TryGetByLocalEntityId(
RenderSceneGeneration generation,
uint localEntityId,
out RenderProjectionRecord record)
{
for (int index = 0; index < _statics.Length; index++)
{
if (_statics[index].Source.LocalEntityId == localEntityId)
{
record = _statics[index];
return true;
}
}
for (int index = 0; index < _dynamics.Length; index++)
{
if (_dynamics[index].Source.LocalEntityId == localEntityId)
{
record = _dynamics[index];
return true;
}
}
record = default;
return false;
}
public int CopyById( public int CopyById(
RenderSceneGeneration generation, RenderSceneGeneration generation,
ReadOnlySpan<RenderProjectionId> ids, ReadOnlySpan<RenderProjectionId> ids,
@ -791,25 +815,6 @@ public sealed class DirectionalShadowCasterFrameTests
return values.Length; return values.Length;
} }
public int GetCellCount(
RenderSceneGeneration generation,
uint fullCellId,
bool dynamic)
{
CellQueries++;
throw new InvalidOperationException("Directional shadows do not query PView cells.");
}
public int CopyCellTo(
RenderSceneGeneration generation,
uint fullCellId,
bool dynamic,
Span<RenderProjectionRecord> destination)
{
CellQueries++;
throw new InvalidOperationException("Directional shadows do not query PView cells.");
}
public void ReplaceStatics( public void ReplaceStatics(
RenderProjectionRecord[] values, RenderProjectionRecord[] values,

View file

@ -316,7 +316,7 @@ public class Issue177StairDescentCameraFloodTests
Assert.Contains( Assert.Contains(
worldData.GetCellStatics(FacilityHub | 0x015Eu).Records, worldData.GetCellStatics(FacilityHub | 0x015Eu).Records,
record => record.Id == projectionId); record => record.Id == projectionId);
Assert.Equal(0, worldData.UnregisteredStaticRenderFallbackCount); Assert.Equal(0, worldData.UnregisteredRenderMembershipCount);
} }
/// <summary> /// <summary>

View file

@ -3,9 +3,23 @@ using AcDream.App.Rendering.Scene;
using AcDream.App.Rendering.Scene.Arch; using AcDream.App.Rendering.Scene.Arch;
using AcDream.App.Rendering.Walk; using AcDream.App.Rendering.Walk;
using AcDream.Core.Physics; using AcDream.Core.Physics;
using AcDream.Core.World;
namespace AcDream.App.Tests.Rendering.Walk; namespace AcDream.App.Tests.Rendering.Walk;
/// <summary>
/// Campaign OVERHAUL S2 chunk 5: <see cref="WalkProductionWorldData"/> no
/// longer sweeps the scene to rebuild membership into bucket dictionaries —
/// <c>GetCellStatics</c>/<c>GetCellDynamics</c>/<c>GetOutdoorStatics</c>/
/// <c>GetOutdoorDynamics</c> are borrowed, on-demand views over
/// <see cref="ShadowObjectRegistry"/>'s retail per-cell part-entry product,
/// resolved back to a <see cref="RenderProjectionRecord"/> through
/// <see cref="RenderSceneQuery.TryGetByLocalEntityId"/>. Every test below
/// drives the REAL production path end to end — a real (bare, no-DAT)
/// <see cref="ShadowObjectRegistry"/> and a real <see cref="ArchRenderScene"/>
/// — rather than a hand-fed bucket dictionary, since the resolution itself IS
/// the thing under test now.
/// </summary>
public sealed class WalkProductionWorldDataTests public sealed class WalkProductionWorldDataTests
{ {
[Fact] [Fact]
@ -79,288 +93,6 @@ public sealed class WalkProductionWorldDataTests
WalkProductionWorldData.BuildingShellBucketCellId(building)); WalkProductionWorldData.BuildingShellBucketCellId(building));
} }
[Fact]
public void BucketOutdoorRecord_UsesEveryOutdoorPhysicsShadowCellAcrossLandblockEdge()
{
RenderProjectionRecord record = Record(
id: 0x1234u,
position: new Vector3(191f, 191f, 0f));
var buckets = new Dictionary<uint, List<RenderProjectionRecord>>();
WalkProductionWorldData.BucketOutdoorRecord(
in record,
[0xF07F0040u, 0xF0800001u, 0xF4180101u],
buckets,
renderCenterLbX: 0xF0,
renderCenterLbY: 0x7F);
Assert.Equal([0xF07F0040u, 0xF0800001u], buckets.Keys.Order());
Assert.All(buckets.Values, bucket => Assert.Equal(record, Assert.Single(bucket)));
}
[Fact]
public void BucketOutdoorRecord_UnregisteredEffectFallsBackToRootPositionCell()
{
RenderProjectionRecord record = Record(
id: 0x5678u,
position: new Vector3(193f, 25f, 0f));
var buckets = new Dictionary<uint, List<RenderProjectionRecord>>();
WalkProductionWorldData.BucketOutdoorRecord(
in record,
Array.Empty<uint>(),
buckets,
renderCenterLbX: 0xEF,
renderCenterLbY: 0x7F);
Assert.Equal([0xF07F0002u], buckets.Keys);
Assert.Equal(record, Assert.Single(buckets[0xF07F0002u]));
}
[Fact]
public void BucketIndoorRecord_InstallsCrossCellPartInBothInteriorCells()
{
RenderProjectionRecord record = Record(
id: 0x48A02035u,
position: new Vector3(55.25f, -46.47f, -3f)) with
{
Source = new RenderSourceMetadata() with
{
LocalEntityId = 0x48A02035u,
ParentCellId = 0x8A02015Fu,
},
};
var indoor = new Dictionary<uint, List<RenderProjectionRecord>>();
var outdoor = new Dictionary<uint, List<RenderProjectionRecord>>();
WalkProductionWorldData.BucketIndoorRecord(
in record,
[0x8A02015Fu, 0x8A02015Eu],
indoor,
outdoor);
Assert.Equal([0x8A02015Eu, 0x8A02015Fu], indoor.Keys.Order());
Assert.All(indoor.Values, bucket => Assert.Equal(record, Assert.Single(bucket)));
Assert.Empty(outdoor);
}
[Fact]
public void BucketIndoorRecord_CanCrossAnExitIntoLandscapeCell()
{
RenderProjectionRecord record = Record(
id: 0x48A02035u,
position: Vector3.Zero) with
{
Source = new RenderSourceMetadata() with
{
LocalEntityId = 0x48A02035u,
ParentCellId = 0x8A02015Fu,
},
};
var indoor = new Dictionary<uint, List<RenderProjectionRecord>>();
var outdoor = new Dictionary<uint, List<RenderProjectionRecord>>();
WalkProductionWorldData.BucketIndoorRecord(
in record,
[0x8A02015Fu, 0x8A020021u],
indoor,
outdoor);
Assert.Equal(record, Assert.Single(indoor[0x8A02015Fu]));
Assert.Equal(record, Assert.Single(outdoor[0x8A020021u]));
}
[Fact]
public void BucketDynamicRecord_InstallsMultipartPlayerInEveryCrossedInteriorCell()
{
RenderProjectionRecord record = Record(
id: 0x000F4243u,
position: new Vector3(58.81f, -49.42f, -0.85f)) with
{
Source = new RenderSourceMetadata() with
{
LocalEntityId = 0x000F4243u,
ParentCellId = 0x8A02015Eu,
},
};
var indoor = new Dictionary<uint, List<RenderProjectionRecord>>();
var outdoor = new Dictionary<uint, List<RenderProjectionRecord>>();
WalkProductionWorldData.BucketDynamicRecord(
in record,
[0x8A02015Eu, 0x8A02015Fu, 0x8A0201C1u],
indoor,
outdoor,
renderCenterLbX: 0x8A,
renderCenterLbY: 0x02);
Assert.Equal(
[0x8A02015Eu, 0x8A02015Fu, 0x8A0201C1u],
indoor.Keys.Order());
Assert.All(
indoor.Values,
bucket => Assert.Equal(record, Assert.Single(bucket)));
Assert.Empty(outdoor);
}
[Fact]
public void BucketDynamicRecord_UnregisteredInteriorEffectFallsBackToParentCell()
{
RenderProjectionRecord record = Record(
id: 0x00001234u,
position: Vector3.Zero) with
{
Source = new RenderSourceMetadata() with
{
LocalEntityId = 0x00001234u,
ParentCellId = 0x8A02015Fu,
},
};
var indoor = new Dictionary<uint, List<RenderProjectionRecord>>();
var outdoor = new Dictionary<uint, List<RenderProjectionRecord>>();
WalkProductionWorldData.BucketDynamicRecord(
in record,
Array.Empty<uint>(),
indoor,
outdoor,
renderCenterLbX: 0x8A,
renderCenterLbY: 0x02);
Assert.Equal(record, Assert.Single(indoor[0x8A02015Fu]));
Assert.Empty(outdoor);
}
// Campaign OVERHAUL S2 chunk 4: ResolveDynamicRenderCells is now a direct
// TryGetRetailCellArray read for EVERY dynamic record, children included
// — ShadowObjectRegistry.AttachChild owns Contract B's child-inheritance
// recursion at the registry, so the render-side parent-chain walk this
// test used to exercise no longer exists.
private static ShadowShape ChildBsp(uint gfxObjId) =>
ShadowShape.Bsp(
gfxObjId,
Vector3.Zero,
Quaternion.Identity,
scale: 1f,
localGeometry: ShadowPartGeometry.Create(
new FlatCollisionSphere(Vector3.Zero, 1f), null));
[Fact]
public void ResolveDynamicRenderCells_AttachedChildReadsTheRegistrysRetailCellArrayDirectly()
{
var shadows = new ShadowObjectRegistry();
const uint rootId = 0x5000000Au;
const uint childId = 0x800045EEu;
IReadOnlyList<ShadowShape> rootParts = new[] { ChildBsp(0x02000001u) };
shadows.RegisterMultiPart(
rootId,
new Vector3(12f, 12f, 50f),
Quaternion.Identity,
rootParts,
state: 0u,
flags: EntityCollisionFlags.None,
worldOffsetX: 0f,
worldOffsetY: 0f,
landblockId: 0xF4180000u,
seedCellId: 0xF4180104u,
isStatic: false,
partArray: rootParts);
Assert.True(shadows.TryGetRetailCellArray(rootId, out IReadOnlyList<uint> rootCells));
Assert.NotEmpty(rootCells); // the fixture must actually exercise a flood
// No render-side parent walk any more: Contract B's inheritance is
// owned by the registry at attach time.
Assert.True(shadows.AttachChild(childId, rootId, new[] { ChildBsp(0x02000002u) }));
RenderProjectionRecord wand = Record(
id: childId,
position: Vector3.Zero) with
{
Source = new RenderSourceMetadata() with
{
LocalEntityId = childId,
ParentCellId = 0xF4180104u,
},
EntityPayload = new RenderEntityPayload() with
{
CasterIdentity = RenderCasterIdentityKind.EquippedChild,
},
};
IReadOnlyList<uint> cells =
WalkProductionWorldData.ResolveDynamicRenderCells(
in wand,
id => shadows.TryGetRetailCellArray(id, out IReadOnlyList<uint> c)
? (true, c)
: (false, Array.Empty<uint>()),
shadows.GetOwnerCells,
out bool usedFallback);
Assert.False(usedFallback);
Assert.Equal(rootCells, cells);
}
[Fact]
public void ResolveDynamicRenderCells_UnregisteredEntityFallsBackToOwnerCells()
{
var shadows = new ShadowObjectRegistry();
const uint entityId = 0x00099Fu;
IReadOnlyList<ShadowShape> shapes = new[] { ChildBsp(0x02000003u) };
shadows.RegisterMultiPart(
entityId,
new Vector3(12f, 12f, 50f),
Quaternion.Identity,
shapes,
state: 0u,
flags: EntityCollisionFlags.None,
worldOffsetX: 0f,
worldOffsetY: 0f,
landblockId: 0xF4180000u,
seedCellId: 0xF4180104u,
isStatic: false);
// No partArray supplied above -> no retail cell array registered for
// this entity (item E of the S2 chunk-1 contract), so the ordinary
// collision-flood answer is the conservative fallback.
Assert.False(shadows.TryGetRetailCellArray(entityId, out _));
Assert.NotEmpty(shadows.GetOwnerCells(entityId));
RenderProjectionRecord record = Record(
id: entityId,
position: Vector3.Zero) with
{
Source = new RenderSourceMetadata() with
{
LocalEntityId = entityId,
ParentCellId = 0xF4180104u,
},
};
IReadOnlyList<uint> cells =
WalkProductionWorldData.ResolveDynamicRenderCells(
in record,
id => shadows.TryGetRetailCellArray(id, out IReadOnlyList<uint> c)
? (true, c)
: (false, Array.Empty<uint>()),
shadows.GetOwnerCells,
out bool usedFallback);
Assert.True(usedFallback);
Assert.Equal(shadows.GetOwnerCells(entityId), cells);
}
// -----------------------------------------------------------------
// Campaign OVERHAUL S2 chunk 2: BeginFrame's indoor static sweep now
// borrows membership from ShadowObjectRegistry.TryGetRetailCellArray
// instead of rebuilding it (the deleted ResolveStaticRenderCells /
// ShadowObjectRegistry.ComputeStaticRenderCells pair). These two tests
// drive the REAL production path end to end — a real (bare, no-DAT)
// ShadowObjectRegistry, a real ArchRenderScene, and BeginFrame itself —
// rather than the hardcoded-cell-array style of the Bucket* tests above,
// which exercise only the (unchanged) bucketing primitive.
// -----------------------------------------------------------------
private static ShadowShape Bsp(uint gfxObjId, float radius = 1f) => private static ShadowShape Bsp(uint gfxObjId, float radius = 1f) =>
ShadowShape.Bsp( ShadowShape.Bsp(
gfxObjId, gfxObjId,
@ -388,20 +120,54 @@ public sealed class WalkProductionWorldDataTests
SourceId = sourceId, SourceId = sourceId,
ParentCellId = parentCellId, ParentCellId = parentCellId,
}, },
// Every production entity record carries its MeshRefs
// (RenderProjectionRecordFactory); only an EnvCell SHELL has no
// payload, and ArchRenderScene keeps shells out of the
// LocalEntityId index on exactly that distinction.
EntityPayload = new RenderEntityPayload() with
{
MeshRefs = [new MeshRef(sourceId, Matrix4x4.Identity)],
},
}; };
[Fact] private static RenderProjectionRecord DynamicRecord(
public void BeginFrame_IndoorStatic_RegisteredEntityUsesRegistryRetailCellArray() uint entityId, uint sourceId, uint parentCellId) =>
IndoorStaticRecord(entityId, sourceId, parentCellId) with
{ {
const uint entityId = 0x48A02001u; Id = RenderProjectionId.FromRaw(0x0100_0000_0000_0000u | entityId),
const uint retailCellId = 0x8A02015Fu; ProjectionClass = RenderProjectionClass.LiveDynamicRoot,
// A DECOY parent cell, deliberately different from the registered };
// retail array's cell, so the assertions below can only pass if the
// indoor sweep actually consulted the registry rather than falling
// back to the authored parent.
const uint decoyParentCellId = 0x8A0201C1u;
var shadows = new ShadowObjectRegistry(); private static RenderProjectionRecord OutdoorStaticRecord(
uint entityId, uint sourceId, uint cellId, bool isBuildingShell = false) =>
new RenderProjectionRecord() with
{
Id = RenderProjectionId.FromRaw(0x0200_0000_0000_0000u | entityId),
ProjectionClass = RenderProjectionClass.OutdoorStatic,
OwnerIncarnation = RenderOwnerIncarnation.FromRaw(1),
Transform = new RenderTransform(Matrix4x4.Identity),
PreviousTransform = new PreviousRenderTransform(Matrix4x4.Identity),
Residency = new RenderSpatialResidency(
RenderSpatialBucket.FromRaw(cellId), cellId & 0xFFFF0000u, cellId),
Flags = RenderProjectionFlags.Draw,
Source = new RenderSourceMetadata() with
{
LocalEntityId = entityId,
SourceId = sourceId,
ParentCellId = cellId,
},
EntityPayload = new RenderEntityPayload() with
{
IsBuildingShell = isBuildingShell,
},
};
private static void Register(
ShadowObjectRegistry shadows,
uint entityId,
uint seedCellId,
uint landblockId)
{
IReadOnlyList<ShadowShape> parts = new[] { Bsp(0x01001234u) }; IReadOnlyList<ShadowShape> parts = new[] { Bsp(0x01001234u) };
shadows.RegisterMultiPart( shadows.RegisterMultiPart(
entityId, entityId,
@ -412,10 +178,26 @@ public sealed class WalkProductionWorldDataTests
flags: EntityCollisionFlags.None, flags: EntityCollisionFlags.None,
worldOffsetX: 0f, worldOffsetX: 0f,
worldOffsetY: 0f, worldOffsetY: 0f,
landblockId: 0x8A020000u, landblockId: landblockId,
seedCellId: retailCellId, seedCellId: seedCellId,
isStatic: true, isStatic: true,
partArray: parts); partArray: parts);
}
[Fact]
public void GetCellStatics_RegisteredEntityIsResolvedByLocalEntityIdAtTheRetailCell()
{
const uint entityId = 0x48A02001u;
const uint retailCellId = 0x8A02015Fu;
// A DECOY parent cell, deliberately different from the registered
// retail array's cell, so the assertions below can only pass if the
// per-cell view actually consulted the registry rather than the
// authored parent (deleted alongside the sweep this test used to
// exercise).
const uint decoyParentCellId = 0x8A0201C1u;
var shadows = new ShadowObjectRegistry();
Register(shadows, entityId, retailCellId, landblockId: 0x8A020000u);
Assert.True(shadows.TryGetRetailCellArray(entityId, out IReadOnlyList<uint> retailCells)); Assert.True(shadows.TryGetRetailCellArray(entityId, out IReadOnlyList<uint> retailCells));
Assert.Equal(new[] { retailCellId }, retailCells); Assert.Equal(new[] { retailCellId }, retailCells);
@ -435,34 +217,265 @@ public sealed class WalkProductionWorldDataTests
Assert.DoesNotContain( Assert.DoesNotContain(
worldData.GetCellStatics(decoyParentCellId).Records, worldData.GetCellStatics(decoyParentCellId).Records,
record => record.Id == projection.Id); record => record.Id == projection.Id);
Assert.Equal(0, worldData.UnregisteredStaticRenderFallbackCount); Assert.Equal(0, worldData.UnregisteredRenderMembershipCount);
} }
[Fact] [Fact]
public void BeginFrame_IndoorStatic_UnregisteredEntityFallsBackToParentCellAndCountsFallback() public void GetCellStatics_RegistryAheadOfSceneContributesToNoCellAndCountsFallback()
{ {
const uint entityId = 0x48A02002u; const uint entityId = 0x48A02002u;
const uint retailCellId = 0x8A02015Fu;
// The registry HAS flooded this entity into its retail CELLARRAY —
// exactly the streaming-window race chunk 5 keeps as the ONE
// remaining fallback: the physics publisher (registry) ran before
// the presentation journal applied this frame's projected record.
var shadows = new ShadowObjectRegistry();
Register(shadows, entityId, retailCellId, landblockId: 0x8A020000u);
Assert.True(shadows.TryGetRetailCellArray(entityId, out _));
RenderSceneGeneration generation = RenderSceneGeneration.FromRaw(1);
using var scene = new ArchRenderScene(generation);
// Deliberately no scene.Apply — the projected record does not exist
// yet this frame.
var worldData = new WalkProductionWorldData(new WalkBuildingRegistry(), shadows);
worldData.BeginFrame(
scene.OpenQuery(), 0x8A020000u, renderCenterLbX: 0x8A, renderCenterLbY: 0x02);
Assert.Equal(0, worldData.GetCellStatics(retailCellId).Records.Count);
Assert.Equal(1, worldData.UnregisteredRenderMembershipCount);
}
[Fact]
public void GetCellStatics_UnregisteredEntityContributesToNoCellWithoutCounting()
{
// The OPPOSITE race: the scene has a projected record, but the
// registry never registered a retail CELLARRAY for this entity at
// all. Under the borrowed-view model this entity is never visited
// (nothing in the registry names it), so it correctly appears in NO
// cell and does not inflate the one remaining fallback counter —
// that counter only tracks entities the REGISTRY has flooded.
const uint entityId = 0x48A02003u;
const uint parentCellId = 0x8A02015Fu; const uint parentCellId = 0x8A02015Fu;
// Bare, empty registry: entityId is never registered, so
// TryGetRetailCellArray must answer false for it.
var shadows = new ShadowObjectRegistry(); var shadows = new ShadowObjectRegistry();
Assert.False(shadows.TryGetRetailCellArray(entityId, out _)); Assert.False(shadows.TryGetRetailCellArray(entityId, out _));
RenderSceneGeneration generation = RenderSceneGeneration.FromRaw(1); RenderSceneGeneration generation = RenderSceneGeneration.FromRaw(1);
using var scene = new ArchRenderScene(generation); using var scene = new ArchRenderScene(generation);
RenderProjectionRecord projection = RenderProjectionRecord projection =
IndoorStaticRecord(entityId, sourceId: 0x02000002u, parentCellId); IndoorStaticRecord(entityId, sourceId: 0x02000003u, parentCellId);
scene.Apply([RenderProjectionDelta.Register(generation, 1, projection)]); scene.Apply([RenderProjectionDelta.Register(generation, 1, projection)]);
var worldData = new WalkProductionWorldData(new WalkBuildingRegistry(), shadows);
worldData.BeginFrame(
scene.OpenQuery(), 0x8A020000u, renderCenterLbX: 0x8A, renderCenterLbY: 0x02);
Assert.Equal(0, worldData.GetCellStatics(parentCellId).Records.Count);
Assert.Equal(0, worldData.UnregisteredRenderMembershipCount);
}
[Fact]
public void UnregisteredRenderMembershipCount_CountsDistinctEntitiesNotCellVisits()
{
// One entity crossing SEVERAL cells — outdoor, since the bbox flood's
// fixed outdoor expansion reliably crosses cells even against a bare
// registry with no portal DAT data to cross INDOOR cells with (see
// GetOutdoorStatics_UsesTheSameRegistryDrivenViewAsIndoor) — all
// unresolved in the scene, must count once total, not once per cell
// the registry flooded it into.
const uint entityId = 0x87640002u;
const uint seedCellId = 0x87640030u;
IReadOnlyList<ShadowShape> parts = new[] { Bsp(0x01001234u) };
var shadows = new ShadowObjectRegistry();
shadows.RegisterMultiPart(
entityId,
Vector3.Zero,
Quaternion.Identity,
parts,
state: 0u,
flags: EntityCollisionFlags.None,
worldOffsetX: 0f,
worldOffsetY: 0f,
landblockId: 0x87640000u,
seedCellId: seedCellId,
isStatic: true,
partArray: parts);
Assert.True(shadows.TryGetRetailCellArray(entityId, out IReadOnlyList<uint> cells));
Assert.True(cells.Count >= 2, "fixture must actually cross more than one cell");
RenderSceneGeneration generation = RenderSceneGeneration.FromRaw(1);
using var scene = new ArchRenderScene(generation);
var worldData = new WalkProductionWorldData(new WalkBuildingRegistry(), shadows);
worldData.BeginFrame(
scene.OpenQuery(), 0x87640000u, renderCenterLbX: 0x87, renderCenterLbY: 0x64);
foreach (uint cellId in cells)
Assert.Equal(0, worldData.GetOutdoorStatics(cellId).Records.Count);
Assert.Equal(1, worldData.UnregisteredRenderMembershipCount);
}
[Fact]
public void GetCellDynamics_OnlyReturnsDynamicClassRecordsFromTheSameCell()
{
const uint staticEntityId = 0x48A02010u;
const uint dynamicEntityId = 0x48A02011u;
const uint sharedCellId = 0x8A02015Fu;
var shadows = new ShadowObjectRegistry();
Register(shadows, staticEntityId, sharedCellId, landblockId: 0x8A020000u);
Register(shadows, dynamicEntityId, sharedCellId, landblockId: 0x8A020000u);
RenderSceneGeneration generation = RenderSceneGeneration.FromRaw(1);
using var scene = new ArchRenderScene(generation);
RenderProjectionRecord staticProjection =
IndoorStaticRecord(staticEntityId, sourceId: 0x02000010u, sharedCellId);
RenderProjectionRecord dynamicProjection =
DynamicRecord(dynamicEntityId, sourceId: 0x02000011u, sharedCellId);
scene.Apply([
RenderProjectionDelta.Register(generation, 1, staticProjection),
RenderProjectionDelta.Register(generation, 2, dynamicProjection),
]);
var worldData = new WalkProductionWorldData(new WalkBuildingRegistry(), shadows); var worldData = new WalkProductionWorldData(new WalkBuildingRegistry(), shadows);
worldData.BeginFrame( worldData.BeginFrame(
scene.OpenQuery(), 0x8A020000u, renderCenterLbX: 0x8A, renderCenterLbY: 0x02); scene.OpenQuery(), 0x8A020000u, renderCenterLbX: 0x8A, renderCenterLbY: 0x02);
Assert.Contains( Assert.Contains(
worldData.GetCellStatics(parentCellId).Records, worldData.GetCellStatics(sharedCellId).Records,
record => record.Id == staticProjection.Id);
Assert.DoesNotContain(
worldData.GetCellStatics(sharedCellId).Records,
record => record.Id == dynamicProjection.Id);
Assert.Contains(
worldData.GetCellDynamics(sharedCellId).Records,
record => record.Id == dynamicProjection.Id);
Assert.DoesNotContain(
worldData.GetCellDynamics(sharedCellId).Records,
record => record.Id == staticProjection.Id);
}
[Fact]
public void GetCellStatics_ExcludesBuildingShellRecordsFromTheSameCell()
{
const uint shellEntityId = 0x48A02020u;
const uint ordinaryEntityId = 0x48A02021u;
const uint sharedCellId = 0x8A02015Fu;
var shadows = new ShadowObjectRegistry();
Register(shadows, shellEntityId, sharedCellId, landblockId: 0x8A020000u);
Register(shadows, ordinaryEntityId, sharedCellId, landblockId: 0x8A020000u);
RenderSceneGeneration generation = RenderSceneGeneration.FromRaw(1);
using var scene = new ArchRenderScene(generation);
RenderProjectionRecord shellProjection =
IndoorStaticRecord(shellEntityId, sourceId: 0x02000020u, sharedCellId) with
{
EntityPayload = new RenderEntityPayload() with { IsBuildingShell = true },
};
RenderProjectionRecord ordinaryProjection =
IndoorStaticRecord(ordinaryEntityId, sourceId: 0x02000021u, sharedCellId);
scene.Apply([
RenderProjectionDelta.Register(generation, 1, shellProjection),
RenderProjectionDelta.Register(generation, 2, ordinaryProjection),
]);
var worldData = new WalkProductionWorldData(new WalkBuildingRegistry(), shadows);
worldData.BeginFrame(
scene.OpenQuery(), 0x8A020000u, renderCenterLbX: 0x8A, renderCenterLbY: 0x02);
Assert.DoesNotContain(
worldData.GetCellStatics(sharedCellId).Records,
record => record.Id == shellProjection.Id);
Assert.Contains(
worldData.GetCellStatics(sharedCellId).Records,
record => record.Id == ordinaryProjection.Id);
}
[Fact]
public void GetOutdoorStatics_UsesTheSameRegistryDrivenViewAsIndoor()
{
const uint entityId = 0x87640001u;
const uint outdoorCellId = 0x87640030u;
var shadows = new ShadowObjectRegistry();
Register(shadows, entityId, outdoorCellId, landblockId: 0x87640000u);
Assert.True(shadows.TryGetRetailCellArray(entityId, out IReadOnlyList<uint> retailCells));
// The outdoor bbox flood's fixed expansion crosses more than the
// seed cell even for a small radius; only the seed cell's own
// membership matters for this test.
Assert.Contains(outdoorCellId, retailCells);
RenderSceneGeneration generation = RenderSceneGeneration.FromRaw(1);
using var scene = new ArchRenderScene(generation);
RenderProjectionRecord projection =
OutdoorStaticRecord(entityId, sourceId: 0x02000030u, outdoorCellId);
scene.Apply([RenderProjectionDelta.Register(generation, 1, projection)]);
var worldData = new WalkProductionWorldData(new WalkBuildingRegistry(), shadows);
worldData.BeginFrame(
scene.OpenQuery(), 0x87640000u, renderCenterLbX: 0x87, renderCenterLbY: 0x64);
Assert.Contains(
worldData.GetOutdoorStatics(outdoorCellId).Records,
record => record.Id == projection.Id); record => record.Id == projection.Id);
Assert.Equal(1, worldData.UnregisteredStaticRenderFallbackCount); }
[Fact]
public void GetCellStatics_CachesTheResultForTheRestOfTheFrame()
{
const uint entityId = 0x48A02030u;
const uint retailCellId = 0x8A02015Fu;
var shadows = new ShadowObjectRegistry();
Register(shadows, entityId, retailCellId, landblockId: 0x8A020000u);
RenderSceneGeneration generation = RenderSceneGeneration.FromRaw(1);
using var scene = new ArchRenderScene(generation);
RenderProjectionRecord projection =
IndoorStaticRecord(entityId, sourceId: 0x02000030u, retailCellId);
scene.Apply([RenderProjectionDelta.Register(generation, 1, projection)]);
var worldData = new WalkProductionWorldData(new WalkBuildingRegistry(), shadows);
worldData.BeginFrame(
scene.OpenQuery(), 0x8A020000u, renderCenterLbX: 0x8A, renderCenterLbY: 0x02);
WalkFrameStaticRecords first = worldData.GetCellStatics(retailCellId);
WalkFrameStaticRecords second = worldData.GetCellStatics(retailCellId);
Assert.Equal(first.Records.Array, second.Records.Array);
Assert.Equal(first.Records.Offset, second.Records.Offset);
Assert.Equal(first.Records.Count, second.Records.Count);
}
[Fact]
public void StaticBucketContains_FindsARegisteredSourceIdInItsRetailCell()
{
const uint entityId = 0x48A02040u;
const uint sourceId = 0x020009A2u;
const uint retailCellId = 0xF4180112u;
var shadows = new ShadowObjectRegistry();
Register(shadows, entityId, retailCellId, landblockId: 0xF4180000u);
RenderSceneGeneration generation = RenderSceneGeneration.FromRaw(1);
using var scene = new ArchRenderScene(generation);
RenderProjectionRecord projection =
IndoorStaticRecord(entityId, sourceId, retailCellId);
scene.Apply([RenderProjectionDelta.Register(generation, 1, projection)]);
var worldData = new WalkProductionWorldData(new WalkBuildingRegistry(), shadows);
worldData.BeginFrame(
scene.OpenQuery(), 0xF4180000u, renderCenterLbX: 0xF4, renderCenterLbY: 0x18);
Assert.True(worldData.StaticBucketContains(retailCellId, sourceId));
Assert.False(worldData.StaticBucketContains(retailCellId, sourceId + 1));
Assert.False(worldData.StaticBucketContains(0xF4180107u, sourceId));
} }
private static RenderProjectionRecord Record(uint id, Vector3 position) => private static RenderProjectionRecord Record(uint id, Vector3 position) =>

View file

@ -1333,6 +1333,73 @@ public sealed class LandblockPhysicsPublisherTests
Resolved = new Dictionary<ushort, ResolvedPolygon>(), Resolved = new Dictionary<ushort, ResolvedPolygon>(),
}; };
/// <summary>
/// Campaign OVERHAUL S2 chunk 5 closeout (G2 self-gate finding): a DAT
/// static with visual parts but no collision geometry is still flooded
/// into its cells by retail (calc_cross_cells_static 0x00515160 bbox route
/// + add_shadows_to_cells 0x00514ae0 AddPartsShadow rows), so the static
/// publisher registers it render-only — retail cell array + part entries,
/// zero collision rows. Before this pin the publisher skipped it outright
/// and, with the walk's parent-cell fallback deleted, the Facility Hub's
/// decorative wall panels vanished.
/// </summary>
[Fact]
public void CompletePublication_NonCollidingStaticRegistersRenderOnly()
{
const uint gfxObjId = 0x01000079u;
var fixture = Fixture();
fixture.Cache.RegisterGfxObjForTest(gfxObjId, RenderOnlyGfx(radius: 0.8f));
WorldEntity entity = new()
{
Id = 0x80A9B402u,
SourceGfxObjOrSetupId = gfxObjId,
Position = new Vector3(12f, 12f, 0f),
Rotation = Quaternion.Identity,
MeshRefs = [new MeshRef(gfxObjId, Matrix4x4.Identity)],
};
Publish(fixture.Publisher, Build(FirstLandblock, [entity]));
ShadowObjectRegistry shadows = fixture.Engine.ShadowObjects;
// No collision row anywhere (Contract B: nothing to contribute).
Assert.Empty(shadows.AllEntriesForDebug());
// But the retail render product exists: cell array + one part entry.
Assert.True(shadows.TryGetRetailCellArray(entity.Id, out var cells));
Assert.NotEmpty(cells);
Assert.Equal(RetailCellArrayRoute.BoundingBox, shadows.GetRetailCellArrayRoute(entity.Id));
int matching = 0;
foreach (RetailPartEntry part in shadows.GetRetailPartEntriesInCell(cells[0]))
{
if (part.EntityId != entity.Id)
continue;
matching++;
Assert.Equal(gfxObjId, part.GfxObjId);
}
Assert.Equal(1, matching);
Assert.Equal(0, fixture.Publisher.Diagnostics.StaticBspOwnerCount);
Assert.Equal(0, fixture.Publisher.Diagnostics.StaticCylinderOwnerCount);
}
/// <summary>A GfxObj with visual bounds and NO physics BSP — a purely
/// decorative static part.</summary>
private static GfxObjPhysics RenderOnlyGfx(float radius) => new()
{
BoundingSphere = new Sphere
{
Origin = Vector3.Zero,
Radius = radius,
},
PhysicsPolygons = new Dictionary<ushort, Polygon>(),
Vertices = new VertexArray(),
Resolved = new Dictionary<ushort, ResolvedPolygon>(),
VisualBounds = new FlatGfxObjVisualBounds(
new Vector3(-radius),
new Vector3(radius),
Vector3.Zero,
radius,
new Vector3(radius)),
};
private static GfxObj PhysicsGfx() => new() private static GfxObj PhysicsGfx() => new()
{ {
Flags = DatReaderWriter.Enums.GfxObjFlags.HasPhysics, Flags = DatReaderWriter.Enums.GfxObjFlags.HasPhysics,

View file

@ -108,4 +108,77 @@ public sealed class LandblockPhysicsContentBuilderStaticSphereTests
Assert.Equal(expectedOrdered[i].CylHeight, entries[i].CylHeight); Assert.Equal(expectedOrdered[i].CylHeight, entries[i].CylHeight);
} }
} }
/// <summary>
/// Campaign OVERHAUL S2 chunk 5 closeout (G2 self-gate finding): a DAT
/// static whose GfxObj has visual geometry but no physics BSP is still a
/// CPhysicsObj retail floods into its cells (calc_cross_cells_static
/// 0x00515160 bbox route over the part array + add_shadows_to_cells
/// 0x00514ae0 AddPartsShadow rows), so the Content publisher registers it
/// render-only: retail cell array + part entry, zero collision rows, and
/// it still counts as a no-collision owner.
/// </summary>
[Fact]
public void PublishStaticCollision_NonCollidingGfxObjStatic_RegistersRenderOnly()
{
const uint gfxObjId = 0x01000043u;
var gfx = new GfxObj
{
VertexArray = new VertexArray
{
Vertices = new Dictionary<ushort, SWVertex>
{
[0] = new SWVertex { Origin = new Vector3(-0.5f, -0.5f, 0f) },
[1] = new SWVertex { Origin = new Vector3(0.5f, -0.5f, 0f) },
[2] = new SWVertex { Origin = new Vector3(0f, 0.5f, 1.2f) },
},
},
};
FlatGfxObjCollisionAsset asset = FlatCollisionAssetBuilder.FlattenGfxObj(gfx);
Assert.True(asset.PhysicsBsp.RootIndex < 0); // the fixture really has no physics
Assert.NotNull(asset.VisualBounds);
var entity = new WorldEntity
{
Id = 0x80A9B402u,
SourceGfxObjOrSetupId = gfxObjId,
Position = new Vector3(12f, 12f, 0f),
Rotation = Quaternion.Identity,
MeshRefs = new[] { new MeshRef(gfxObjId, Matrix4x4.Identity) },
};
var landblock = new LoadedLandblock(
LandblockId,
new LandBlock { Terrain = new TerrainInfo[81], Height = new byte[81] },
new[] { entity });
var collisions = new LandblockCollisionBuild(
ImmutableDictionary<uint, FlatGfxObjCollisionAsset>.Empty.Add(gfxObjId, asset),
ImmutableDictionary<uint, FlatSetupCollision>.Empty,
ImmutableDictionary<uint, FlatCellStructureCollisionAsset>.Empty,
ImmutableDictionary<uint, FlatEnvCellTopology>.Empty,
ImmutableArray.Create(gfxObjId),
ImmutableArray<uint>.Empty,
ImmutableArray<uint>.Empty);
var engine = new PhysicsEngine();
var cache = new PhysicsDataCache();
LandblockPhysicsContentBuilder.CachePreparedObjects(cache, collisions);
LandblockPhysicsContentBuilder.StaticCollisionPublication publication =
LandblockPhysicsContentBuilder.PublishStaticCollision(
engine, cache, landblock, collisions, origin: Vector3.Zero);
Assert.Equal(0, publication.SetupOwnerCount);
Assert.Equal(0, publication.BspOwnerCount);
Assert.Equal(1, publication.NoCollisionCount);
Assert.Empty(engine.ShadowObjects.AllEntriesForDebug());
Assert.True(engine.ShadowObjects.TryGetRetailCellArray(entity.Id, out var cells));
Assert.NotEmpty(cells);
Assert.Equal(
RetailCellArrayRoute.BoundingBox,
engine.ShadowObjects.GetRetailCellArrayRoute(entity.Id));
var parts = engine.ShadowObjects.GetRetailPartEntriesInCell(cells[0])
.Where(part => part.EntityId == entity.Id)
.ToArray();
Assert.Single(parts);
Assert.Equal(gfxObjId, parts[0].GfxObjId);
}
} }

View file

@ -867,4 +867,388 @@ public class ShadowObjectRegistryRetailCellArrayTests
Assert.True(reg.TryGetRetailCellArray(childId, out var childCells)); Assert.True(reg.TryGetRetailCellArray(childId, out var childCells));
Assert.Equal(rootCellsAfter, childCells); Assert.Equal(rootCellsAfter, childCells);
} }
// -------------------------------------------------------------------
// Campaign OVERHAUL S2 chunk 5 (item B): a live entity whose collision
// dispatch produced NO shapes but whose Setup still has a visual part
// array (the retail short-lived spell/visual effect-object case) still
// registers for render — Contract A's cylsphere-vs-bbox TEST reads the
// COLLISION shapes (none here, so it is always false) and the flood
// unconditionally falls through to the bbox route over the WHOLE part
// array. No ShadowEntry collision row is ever published; only the
// retail CELLARRAY and per-cell RetailPartEntry rows are.
// -------------------------------------------------------------------
[Fact]
public void RegisterMultiPart_EmptyShapesWithPartArray_RegistersRenderOnlyNoCollisionRows()
{
var reg = new ShadowObjectRegistry();
const uint entityId = 0x50u;
IReadOnlyList<ShadowShape> partArray = new[] { Bsp(0x0100_0060u, radius: 2f) };
reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, Array.Empty<ShadowShape>(),
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
seedCellId: CellA, isStatic: true, partArray: partArray);
Assert.True(reg.TryGetRetailCellArray(entityId, out var cells));
Assert.Equal(new[] { CellA }, cells);
Assert.Equal(RetailCellArrayRoute.BoundingBox, reg.GetRetailCellArrayRoute(entityId));
var entries = reg.GetRetailPartEntriesInCell(CellA)
.Where(e => e.EntityId == entityId).ToList();
Assert.Single(entries);
Assert.Equal(0x0100_0060u, entries[0].GfxObjId);
// No collision row anywhere: shadow_object_list gets nothing for an
// object with no part to contribute to it (Contract B).
Assert.Empty(reg.GetOwnerCells(entityId));
}
[Fact]
public void RegisterMultiPart_EmptyShapesAndNoPartArray_StillDeregisters()
{
// Byte-for-byte prior behavior: a caller that supplies neither
// collision shapes nor a part array is a genuine deregistration, not
// a render-only registration.
var reg = new ShadowObjectRegistry();
const uint entityId = 0x51u;
IReadOnlyList<ShadowShape> parts = new[] { Bsp(0x0100_0061u) };
reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, parts,
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
seedCellId: CellA, isStatic: true, partArray: parts);
Assert.True(reg.TryGetRetailCellArray(entityId, out _));
reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, Array.Empty<ShadowShape>(),
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
seedCellId: CellA, isStatic: true);
Assert.False(reg.TryGetRetailCellArray(entityId, out var cells));
Assert.Empty(cells);
Assert.Empty(reg.GetOwnerCells(entityId));
Assert.Empty(reg.GetRetailPartEntriesInCell(CellA));
}
[Fact]
public void RegisterMultiPart_RenderOnly_KeepWhenEmptyPreservesThePriorRegistration()
{
var reg = new ShadowObjectRegistry();
const uint entityId = 0x52u;
IReadOnlyList<ShadowShape> partArray = new[] { Bsp(0x0100_0062u, radius: 2f) };
reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, Array.Empty<ShadowShape>(),
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
seedCellId: CellA, isStatic: true, partArray: partArray);
Assert.True(reg.TryGetRetailCellArray(entityId, out var before));
Assert.Equal(new[] { CellA }, before);
// landblockId 0 (no seedCellId either) => DeriveOutdoorSeed returns 0
// => the retail keep-when-empty gate (pc:283540): neither product is
// touched.
reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, Array.Empty<ShadowShape>(),
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, landblockId: 0u,
isStatic: true, partArray: partArray);
Assert.True(reg.TryGetRetailCellArray(entityId, out var after));
Assert.Equal(before, after);
Assert.NotEmpty(reg.GetRetailPartEntriesInCell(CellA));
}
[Fact]
public void RegisterMultiPart_RenderOnly_DeregisterClearsEveryProduct()
{
var reg = new ShadowObjectRegistry();
const uint entityId = 0x53u;
IReadOnlyList<ShadowShape> partArray = new[] { Bsp(0x0100_0063u, radius: 2f) };
reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, Array.Empty<ShadowShape>(),
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
seedCellId: CellA, isStatic: true, partArray: partArray);
Assert.True(reg.TryGetRetailCellArray(entityId, out _));
reg.Deregister(entityId);
Assert.False(reg.TryGetRetailCellArray(entityId, out var cells));
Assert.Empty(cells);
Assert.Empty(reg.GetRetailPartEntriesInCell(CellA));
}
[Fact]
public void UpdatePosition_RenderOnly_RecomputesTheRetailCellArrayAtTheNewPosition()
{
var reg = new ShadowObjectRegistry();
const uint entityId = 0x54u;
IReadOnlyList<ShadowShape> partArray = new[] { Bsp(0x0100_0064u, radius: 2f) };
reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, Array.Empty<ShadowShape>(),
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
seedCellId: CellA, isStatic: true, partArray: partArray);
Assert.True(reg.TryGetRetailCellArray(entityId, out var before));
Assert.Equal(new[] { CellA }, before);
var moved = new Vector3(42f, 12f, 50f); // 30 m away in X -> CellB
reg.UpdatePosition(entityId, moved, Quaternion.Identity, OffX, OffY, LbId);
Assert.True(reg.TryGetRetailCellArray(entityId, out var after));
Assert.Equal(new[] { CellB }, after);
Assert.DoesNotContain(
reg.GetRetailPartEntriesInCell(CellA),
e => e.EntityId == entityId);
var movedEntries = reg.GetRetailPartEntriesInCell(CellB)
.Where(e => e.EntityId == entityId).ToList();
Assert.Single(movedEntries);
Assert.Equal(0x0100_0064u, movedEntries[0].GfxObjId);
// Still no collision row after the move.
Assert.Empty(reg.GetOwnerCells(entityId));
}
// -------------------------------------------------------------------
// Chunk 5 closeout: a streaming reflood (RefloodOwnerForLandblock, the
// path landblock replacement commits and the Content builder's
// post-publication reflood both take) is retail's recalc_cross_cells
// over the SAME CPartArray, so the retail render product survives it.
// Before this pin the reflood re-registered from the collision shapes
// only and every reflooded owner vanished from the walk.
// -------------------------------------------------------------------
[Fact]
public void RefloodLandblock_KeepsTheRetailProductOfACollisionOwnerWithAPartArray()
{
var reg = new ShadowObjectRegistry();
const uint entityId = 0x60u;
IReadOnlyList<ShadowShape> collision = new[] { Bsp(0x0100_0070u, radius: 2f) };
IReadOnlyList<ShadowShape> parts = new[]
{
Bsp(0x0100_0070u, radius: 2f),
Bsp(0x0100_0071u, localPosition: new Vector3(0.5f, 0f, 0f), radius: 1f),
};
reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, collision,
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
seedCellId: CellA, isStatic: true, partArray: parts);
Assert.True(reg.TryGetRetailCellArray(entityId, out var before));
var beforeEntries = reg.GetRetailPartEntriesInCell(CellA)
.Where(e => e.EntityId == entityId).Select(e => e.GfxObjId).ToList();
Assert.Equal(new[] { 0x0100_0070u, 0x0100_0071u }, beforeEntries);
reg.RefloodLandblock(LbId);
Assert.True(reg.TryGetRetailCellArray(entityId, out var after));
Assert.Equal(before, after);
Assert.Equal(RetailCellArrayRoute.BoundingBox, reg.GetRetailCellArrayRoute(entityId));
var afterEntries = reg.GetRetailPartEntriesInCell(CellA)
.Where(e => e.EntityId == entityId).Select(e => e.GfxObjId).ToList();
Assert.Equal(beforeEntries, afterEntries);
// The collision product is untouched by the reflood as well.
Assert.Equal(new[] { CellA }, reg.GetOwnerCells(entityId));
}
[Fact]
public void RefloodLandblock_KeepsARenderOnlyOwner()
{
var reg = new ShadowObjectRegistry();
const uint entityId = 0x61u;
IReadOnlyList<ShadowShape> parts = new[] { Bsp(0x0100_0072u, radius: 2f) };
reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, Array.Empty<ShadowShape>(),
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
seedCellId: CellA, isStatic: true, partArray: parts);
Assert.True(reg.TryGetRetailCellArray(entityId, out _));
reg.RefloodLandblock(LbId);
// Still registered, still render-only, still in its cell: the empty
// collision shape list plus the retained part array is the
// render-only registration, not the "nothing supplied" deregistration.
Assert.True(reg.TryGetRetailCellArray(entityId, out var cells));
Assert.Equal(new[] { CellA }, cells);
var entries = reg.GetRetailPartEntriesInCell(CellA)
.Where(e => e.EntityId == entityId).ToList();
Assert.Single(entries);
Assert.Equal(0x0100_0072u, entries[0].GfxObjId);
Assert.Empty(reg.GetOwnerCells(entityId));
}
// -------------------------------------------------------------------
// S2 review fix round (2026-09-03): lifetime symmetry of the retail
// render product. remove_shadows_from_cells (0x00511230) removes the
// CShadowObj row AND the AddPartsShadow rows in one transaction, and
// recurses through children — every acdream path that drops one product
// drops the other.
// -------------------------------------------------------------------
private static int CountRows(ShadowObjectRegistry reg, uint cellId, uint entityId)
=> reg.GetRetailPartEntriesInCell(cellId).Count(e => e.EntityId == entityId);
[Fact]
public void Suspend_ClearsTheRetailProduct_AndTheUnsuspendingMoveRepublishesIt()
{
var reg = new ShadowObjectRegistry();
const uint entityId = 0x70u;
IReadOnlyList<ShadowShape> parts = new[] { Bsp(0x0100_0080u, radius: 2f) };
reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, parts,
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
seedCellId: CellA, isStatic: false, partArray: parts);
Assert.Equal(1, CountRows(reg, CellA, entityId));
Assert.True(reg.Suspend(entityId));
Assert.False(reg.TryGetRetailCellArray(entityId, out _));
Assert.Equal(0, CountRows(reg, CellA, entityId));
Assert.Empty(reg.GetOwnerCells(entityId));
// The route and part array are retained for the republish.
Assert.Equal(RetailCellArrayRoute.BoundingBox, reg.GetRetailCellArrayRoute(entityId));
// A .None commit republishes at the RETAINED (suspended) cells for
// both products — the un-suspend path.
reg.CommitSetPosition(
entityId, Pos, Quaternion.Identity,
seedCellId: CellA, worldOffsetX: OffX, worldOffsetY: OffY,
action: PhysicsShadowCommitAction.None,
crossCellIds: ImmutableArray<uint>.Empty);
Assert.Equal(new[] { CellA }, reg.GetOwnerCells(entityId));
Assert.True(reg.TryGetRetailCellArray(entityId, out var cells));
Assert.Equal(new[] { CellA }, cells);
Assert.Equal(1, CountRows(reg, CellA, entityId));
}
[Fact]
public void AttachChild_AndDetachChild_AdvanceTheMutationRevision()
{
var reg = new ShadowObjectRegistry();
const uint rootId = 0x71u;
const uint childId = 0x72u;
IReadOnlyList<ShadowShape> rootParts = new[] { Bsp(0x0100_0081u, radius: 2f) };
reg.RegisterMultiPart(rootId, Pos, Quaternion.Identity, rootParts,
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
seedCellId: CellA, isStatic: false, partArray: rootParts);
ulong before = reg.MutationRevision;
Assert.True(reg.AttachChild(childId, rootId, new[] { Bsp(0x0100_0082u) }));
Assert.True(reg.MutationRevision > before);
before = reg.MutationRevision;
Assert.True(reg.DetachChild(childId));
Assert.True(reg.MutationRevision > before);
}
[Fact]
public void RegisterMultiPart_OfAnAttachedChild_KeepsInheritingTheRootsCells()
{
var reg = new ShadowObjectRegistry();
const uint rootId = 0x73u;
const uint childId = 0x74u;
IReadOnlyList<ShadowShape> rootParts = new[] { Bsp(0x0100_0083u, radius: 2f) };
reg.RegisterMultiPart(rootId, Pos, Quaternion.Identity, rootParts,
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
seedCellId: CellA, isStatic: false, partArray: rootParts);
Assert.True(reg.AttachChild(childId, rootId, new[] { Bsp(0x0100_0084u) }));
Assert.Equal(1, CountRows(reg, CellA, childId));
// An appearance update re-registers the child at its own (different)
// position with a NEW part array: retail never floods a child, so it
// keeps the root's CELLARRAY and only its parts change.
IReadOnlyList<ShadowShape> newParts = new[] { Bsp(0x0100_0085u, radius: 1f) };
reg.RegisterMultiPart(childId, new Vector3(42f, 12f, 50f), Quaternion.Identity, newParts,
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
seedCellId: CellB, isStatic: false, partArray: newParts);
Assert.True(reg.TryGetRetailCellArray(childId, out var childCells));
Assert.Equal(new[] { CellA }, childCells);
Assert.Equal(0, CountRows(reg, CellB, childId));
var rows = reg.GetRetailPartEntriesInCell(CellA).Where(e => e.EntityId == childId).ToList();
Assert.Single(rows);
Assert.Equal(0x0100_0085u, rows[0].GfxObjId);
}
[Fact]
public void RemoveLandblock_ClearsTheRetailProduct_IncludingRenderOnlyStatics()
{
var reg = new ShadowObjectRegistry();
const uint collidingId = 0x75u;
const uint renderOnlyId = 0x76u;
IReadOnlyList<ShadowShape> parts = new[] { Bsp(0x0100_0086u, radius: 2f) };
reg.RegisterMultiPart(collidingId, Pos, Quaternion.Identity, parts,
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
seedCellId: CellA, isStatic: true, partArray: parts);
reg.RegisterMultiPart(renderOnlyId, Pos, Quaternion.Identity, Array.Empty<ShadowShape>(),
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
seedCellId: CellA, isStatic: true, partArray: parts);
Assert.Equal(1, CountRows(reg, CellA, renderOnlyId));
reg.RemoveLandblock(LbId);
Assert.False(reg.TryGetRetailCellArray(collidingId, out _));
Assert.False(reg.TryGetRetailCellArray(renderOnlyId, out _));
Assert.Empty(reg.GetRetailPartEntriesInCell(CellA));
// Both statics ended with their landblock — nothing left to suspend.
Assert.False(reg.Suspend(collidingId));
Assert.False(reg.Suspend(renderOnlyId));
}
[Fact]
public void RetireOwnerFromLandblock_PrunesTheRetailRowsOfANonRootedOwner()
{
var reg = new ShadowObjectRegistry();
const uint entityId = 0x77u;
IReadOnlyList<ShadowShape> parts = new[] { Bsp(0x0100_0087u, radius: 2f) };
reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, parts,
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
seedCellId: CellA, isStatic: false, partArray: parts);
Assert.Equal(1, CountRows(reg, CellA, entityId));
reg.RetireOwnerFromLandblock(entityId, LbId);
Assert.Equal(0, CountRows(reg, CellA, entityId));
Assert.False(reg.TryGetRetailCellArray(entityId, out _));
Assert.Empty(reg.GetOwnerCells(entityId));
// A live owner survives the prefix retirement (it is still logically
// alive for the reload reflood), so it can still be suspended.
Assert.True(reg.Suspend(entityId));
}
[Fact]
public void ReplaceMultiPartPayload_WithAnEmptyPartArray_KeepsThePriorRows()
{
var reg = new ShadowObjectRegistry();
const uint entityId = 0x78u;
IReadOnlyList<ShadowShape> parts = new[] { Bsp(0x0100_0088u, radius: 2f) };
reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, parts,
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
seedCellId: CellA, isStatic: false, partArray: parts);
reg.ReplaceMultiPartPayload(entityId, Pos, Quaternion.Identity, parts,
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
seedCellId: CellA, isStatic: false, partArray: Array.Empty<ShadowShape>());
// Never a cell-array claim with no drawable rows behind it.
Assert.True(reg.TryGetRetailCellArray(entityId, out var cells));
Assert.Equal(new[] { CellA }, cells);
Assert.Equal(1, CountRows(reg, CellA, entityId));
}
[Fact]
public void CommitSetPosition_None_MovesARenderOnlyOwnerToItsDestinationCell()
{
var reg = new ShadowObjectRegistry();
const uint entityId = 0x79u;
IReadOnlyList<ShadowShape> parts = new[] { Bsp(0x0100_0089u, radius: 1f) };
reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, Array.Empty<ShadowShape>(),
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
seedCellId: CellA, isStatic: false, partArray: parts);
Assert.Equal(1, CountRows(reg, CellA, entityId));
// A render-only owner has no collision cells to republish from, so
// its "cell did not change" commit would otherwise freeze it at its
// registration cells forever (retail review F3): it travels with its
// destination cell instead (AD-117).
reg.CommitSetPosition(
entityId, new Vector3(42f, 12f, 50f), Quaternion.Identity,
seedCellId: CellB, worldOffsetX: OffX, worldOffsetY: OffY,
action: PhysicsShadowCommitAction.None,
crossCellIds: ImmutableArray<uint>.Empty);
Assert.True(reg.TryGetRetailCellArray(entityId, out var cells));
Assert.Equal(new[] { CellB }, cells);
Assert.Equal(0, CountRows(reg, CellA, entityId));
Assert.Equal(1, CountRows(reg, CellB, entityId));
Assert.Empty(reg.GetOwnerCells(entityId));
}
} }