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

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

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

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

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

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

View file

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

File diff suppressed because one or more lines are too long

View file

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

View file

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

View file

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

View file

@ -31,10 +31,11 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource
private readonly HashSet<RenderProjectionId> _selectable = [];
private readonly HashSet<RenderProjectionId> _lightCandidates = [];
private readonly HashSet<RenderProjectionId> _dirty = [];
private readonly Dictionary<uint, HashSet<RenderProjectionId>>
_cellStatics = [];
private readonly Dictionary<uint, HashSet<RenderProjectionId>>
_cellDynamics = [];
// Campaign OVERHAUL S2 chunk 5: presentation-side lookup by the entity's
// stable LocalEntityId — see IRenderSceneQuerySource.TryGetByLocalEntityId.
// Maintained at the same register/update/unregister points as the cell
// indices above; never a membership source.
private readonly Dictionary<uint, RenderProjectionId> _byLocalEntityId = [];
private ArchWorld _world;
private RenderProjectionCounts _counts;
private ulong _lastAppliedJournalSequence;
@ -434,6 +435,23 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource
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(
RenderSceneGeneration generation,
ReadOnlySpan<RenderProjectionId> ids,
@ -499,31 +517,6 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource
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() =>
ArchWorld.Create(
archetypeCapacity: InitialArchetypeCapacity,
@ -809,21 +802,14 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource
if (staticProjection)
{
if (indoor)
{
_indoorCellStatics.Add(record.Id);
AddCell(_cellStatics, record.Residency.FullCellId, record.Id);
}
else
{
_outdoorStatics.Add(record.Id);
}
}
else
{
_dynamics.Add(record.Id);
if (indoor)
AddCell(_cellDynamics, record.Residency.FullCellId, record.Id);
else
if (!indoor)
_outdoorDynamics.Add(record.Id);
if ((record.Flags & RenderProjectionFlags.PortalStraddling) != 0)
_portalStraddlingDynamics.Add(record.Id);
@ -837,6 +823,17 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource
_lightCandidates.Add(record.Id);
if (record.DirtyMask != RenderDirtyMask.None)
_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();
AdvanceDirectionalShadowTopologyRevision();
}
@ -852,12 +849,24 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource
_selectable.Remove(record.Id);
_lightCandidates.Remove(record.Id);
_dirty.Remove(record.Id);
RemoveCell(_cellStatics, record.Residency.FullCellId, record.Id);
RemoveCell(_cellDynamics, record.Residency.FullCellId, record.Id);
if (record.Source.LocalEntityId != 0
&& _byLocalEntityId.TryGetValue(
record.Source.LocalEntityId,
out RenderProjectionId mapped)
&& mapped == record.Id)
{
_byLocalEntityId.Remove(record.Source.LocalEntityId);
}
AdvanceIndexRevision();
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(
in RenderProjectionRecord left,
in RenderProjectionRecord right)
@ -870,6 +879,8 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource
| RenderProjectionFlags.SpatiallyResident;
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.Residency.FullCellId == right.Residency.FullCellId
&& (left.Flags & indexedFlags) == (right.Flags & indexedFlags)
@ -1141,33 +1152,6 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource
projectionClass is RenderProjectionClass.LiveDynamicRoot
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) =>
index switch
{
@ -1223,13 +1207,7 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource
+ _selectable.EnsureCapacity(0)
+ _lightCandidates.EnsureCapacity(0)
+ _dirty.EnsureCapacity(0);
foreach (HashSet<RenderProjectionId> ids in _cellStatics.Values)
slots += ids.EnsureCapacity(0);
foreach (HashSet<RenderProjectionId> ids in _cellDynamics.Values)
slots += ids.EnsureCapacity(0);
long cellLookupSlots =
_cellStatics.EnsureCapacity(0)
+ _cellDynamics.EnsureCapacity(0);
long cellLookupSlots = _byLocalEntityId.EnsureCapacity(0);
return checked(slots * 24L + cellLookupSlots * 40L);
}
@ -1244,8 +1222,7 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource
_selectable.Clear();
_lightCandidates.Clear();
_dirty.Clear();
_cellStatics.Clear();
_cellDynamics.Clear();
_byLocalEntityId.Clear();
}
private void IncrementCount(RenderProjectionClass projectionClass)

View file

@ -557,6 +557,22 @@ internal interface IRenderSceneQuerySource
RenderProjectionId id,
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(
RenderSceneGeneration generation,
ReadOnlySpan<RenderProjectionId> ids,
@ -572,16 +588,10 @@ internal interface IRenderSceneQuerySource
RenderSceneIndex index,
Span<RenderProjectionRecord> destination);
int GetCellCount(
RenderSceneGeneration generation,
uint fullCellId,
bool dynamic);
int CopyCellTo(
RenderSceneGeneration generation,
uint fullCellId,
bool dynamic,
Span<RenderProjectionRecord> destination);
// Campaign OVERHAUL S2 review fix (arch F10): the per-cell scene indices
// (GetCellCount/CopyCellTo, keyed by the single authored FullCellId) are
// deleted — the registry's retail CELLARRAY is the ONLY render-membership
// source (ShadowObjectRegistry.GetRetailPartEntriesInCell).
}
internal readonly struct RenderSceneQuery
@ -626,6 +636,11 @@ internal readonly struct RenderSceneQuery
out RenderProjectionRecord record) =>
Source.TryGet(Generation, id, out record);
public bool TryGetByLocalEntityId(
uint localEntityId,
out RenderProjectionRecord record) =>
Source.TryGetByLocalEntityId(Generation, localEntityId, out record);
public int CopyById(
ReadOnlySpan<RenderProjectionId> ids,
Span<RenderProjectionRecord> destination) =>
@ -644,30 +659,6 @@ internal readonly struct RenderSceneQuery
Span<RenderProjectionRecord> 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 =>
_source
?? throw new InvalidOperationException("The render-scene query is uninitialized.");

View file

@ -9,70 +9,70 @@ namespace AcDream.App.Rendering.Walk;
/// <summary>
/// Campaign FW3.2b-2: the production <see cref="IWalkFrameWorldData"/> over
/// the retained scene (<see cref="RenderSceneQuery"/>) and the FW3.1
/// <see cref="WalkBuildingRegistry"/>. Rebuilt facts per frame via
/// <see cref="BeginFrame"/>:
/// <see cref="WalkBuildingRegistry"/>.
///
/// <list type="bullet">
/// <item>Cell statics — ONE <see cref="RenderSceneQuery.CopyIndexTo"/> sweep
/// over authored indoor statics, bucketed into every cell of
/// <see cref="ShadowObjectRegistry.TryGetRetailCellArray"/>'s retail
/// CELLARRAY. Campaign OVERHAUL S2 chunk 2: membership is BORROWED from that
/// registry — the sole owner of retail's <c>calc_cross_cells_static</c>
/// (0x00515160) → <c>CPartArray::AddPartsShadow</c> (0x00517e40) transaction
/// — never rebuilt in this class. A registered entity's array deliberately
/// includes non-colliding decorations and is distinct from the physics
/// shadow-object index (<see cref="ShadowObjectRegistry.GetOwnerCells"/>).
/// An entity the registry has not registered yet (the streaming window
/// where the static-projection journal published the record before the
/// physics publisher registered the entity — two independent incremental
/// state machines) falls back to its authored
/// <see cref="RenderSourceMetadata.ParentCellId"/> alone and is counted in
/// <see cref="UnregisteredStaticRenderFallbackCount"/>. Per-cell PART
/// entries (<see cref="ShadowObjectRegistry.GetRetailPartEntriesInCell"/>)
/// are not consumed here — buckets stay whole-record per cell, which equals
/// CELLARRAY membership; per-part draw admission is Campaign OVERHAUL S3.</item>
/// <item>Live dynamics — ONE global dynamic-index sweep, bucketed into every
/// cell in the object's retained physics <c>CELLARRAY</c>. Retail feeds that
/// exact array to <c>CPhysicsObj::add_shadows_to_cells</c>, which calls
/// <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>
/// <para>
/// Campaign OVERHAUL S2 chunk 5: cell membership is a BORROWED VIEW over
/// <see cref="ShadowObjectRegistry"/>'s retail per-cell part-entry product
/// (<see cref="ShadowObjectRegistry.GetRetailPartEntriesInCell"/> — retail's
/// <c>CPartArray::AddPartsShadow</c> 0x00517e40 output, keyed by the SAME
/// CELLARRAY <see cref="ShadowObjectRegistry.TryGetRetailCellArray"/>
/// answers). This class no longer sweeps the scene's static/dynamic indices
/// to REBUILD membership: for a queried cell id, <see cref="ResolveCellView"/>
/// walks the registry's per-cell entries (already in retail
/// CELLARRAY-then-part-array insertion order), collapses them to their
/// distinct owning entity ids in that same order, and resolves each id back
/// to its current <see cref="RenderProjectionRecord"/> through
/// <see cref="RenderSceneQuery.TryGetByLocalEntityId"/> — a presentation-side
/// lookup by the SAME <see cref="RenderSourceMetadata.LocalEntityId"/> the
/// registry is keyed by (chunk 5's App-side addition to
/// <c>ArchRenderScene</c>). Statics vs. dynamics are the same
/// <c>ProjectionClass</c> split <c>ArchRenderScene</c> itself uses (a live
/// dynamic root or an equipped child is "dynamic"; every other class is
/// "static"), and building shells are excluded — they draw at their own
/// building's shell turn (retail <c>CPhysicsPart::Draw(parts, 0)</c>
/// @0x0059f331), never at the cell's ordinary <c>DrawObjCell</c> turn.
/// </para>
///
/// The tuple landblock id handed to the classifier is the frame's player
/// landblock, matching the production walk's retained-scene query convention.
/// <para>
/// 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"/>,
/// and <see cref="GetBuildingShellStatics"/> used to materialize their result
/// with <c>_cellScratch[..count]</c> / <c>[.. bucket]</c> — a FRESH
/// <c>RenderProjectionRecord[]</c> allocation per distinct cell/anchor per
/// frame. At a town-density frame (dozens of cells) that was the single
/// largest contributor to the FW3.4 perf checkpoint's 14× frame-allocation
/// regression (1.9 MB/frame p50). <see cref="_arena"/> replaces it: a
/// grow-only buffer, reset to length 0 once per frame in
/// <see cref="BeginFrame"/>, that every materialization call
/// <see cref="AppendToArena"/>s its records into instead of snapshotting a
/// new array — after the arena reaches its steady-state size (a few frames
/// of warmup, same shape as <see cref="_sweepScratch"/>/<see cref="_cellScratch"/>'s
/// existing grow-on-demand pattern), zero further heap allocation occurs
/// here. Every <see cref="WalkFrameStaticRecords.Records"/> segment is
/// <para>Building shells are the ONE exception left to a per-frame sweep:
/// retail's shell/portal machinery is out of S2's scope (the landcell
/// building channel), so <see cref="GetBuildingShellStatics"/> still reads a
/// small per-frame bucket (<c>_shellsByAnchor</c>) filled by a single narrow
/// <see cref="RenderSceneIndex.OutdoorStatic"/> sweep in
/// <see cref="BeginFrame"/> that keeps only <c>IsBuildingShell</c> records —
/// it never resolves ordinary static/dynamic membership.</para>
///
/// <para>Campaign FW3.4a: <see cref="GetCellStatics"/>, <see cref="GetCellDynamics"/>,
/// <see cref="GetOutdoorStatics"/>, <see cref="GetOutdoorDynamics"/>, and
/// <see cref="GetBuildingShellStatics"/> materialize their result into
/// <see cref="_arena"/> — a grow-only buffer reset to length 0 once per frame
/// in <see cref="BeginFrame"/> — rather than a fresh <c>RenderProjectionRecord[]</c>
/// 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
/// (the driver/populator consume it immediately, matching
/// <see cref="_sweepScratch"/>'s existing lifetime contract) — so reusing the
/// same backing array's memory next frame is safe.</para>
/// (the driver/populator consume it immediately) — so reusing the same
/// backing array's memory next frame is safe. Per-cell RESULTS
/// (<see cref="_cellCache"/> etc.) are cleared and re-materialized once per
/// frame on first ask, same as before chunk 5.</para>
/// </summary>
internal sealed class WalkProductionWorldData : IWalkFrameWorldData
{
@ -80,24 +80,32 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
private readonly ShadowObjectRegistry _shadows;
private RenderSceneQuery _scene;
private uint _tupleLandblockId;
private int _renderCenterLbX;
private int _renderCenterLbY;
private readonly Dictionary<uint, WalkFrameStaticRecords> _cellCache = new();
private readonly Dictionary<uint, WalkFrameStaticRecords> _cellDynamicCache = 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, WalkFrameStaticRecords> _outdoorMaterialized = new();
private readonly Dictionary<uint, WalkFrameStaticRecords> _outdoorDynamicsMaterialized = 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[] _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
// this type's own doc comment.
@ -110,455 +118,135 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
{
_buildings = buildings ?? throw new ArgumentNullException(nameof(buildings));
_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>
/// Campaign OVERHAUL S2 chunk 2: how many indoor/outdoor static records
/// this frame fell back to a conservative single-cell membership
/// (indoor: the authored <see cref="RenderSourceMetadata.ParentCellId"/>;
/// outdoor: today's collision-flood
/// <see cref="ShadowObjectRegistry.GetOwnerCells"/> answer) because
/// <see cref="ShadowObjectRegistry.TryGetRetailCellArray"/> had no retail
/// CELLARRAY registered yet for the entity. Retail has no such gap —
/// <c>CEnvCell::init_static_objects</c> installs the CELLARRAY before a
/// static is ever drawable — so a nonzero count here reflects two
/// independent incremental state machines (the static-projection journal
/// versus the physics publisher) racing during streaming, not
/// steady-state behavior; see AD-40's residency reasoning in
/// <see cref="CellTransit.BuildShadowCellSetFromParts"/>. Reset to zero
/// at the start of every <see cref="BeginFrame"/>. Campaign OVERHAUL S2
/// chunk 5 decides this adaptation's fate against the connected-route
/// count.
/// Campaign OVERHAUL S2 chunk 5: how many DISTINCT entities this frame
/// were present in the registry's retail CELLARRAY (so
/// <see cref="ShadowObjectRegistry.GetRetailPartEntriesInCell"/> named
/// them) but had no resolvable <see cref="RenderProjectionRecord"/> yet
/// through <see cref="RenderSceneQuery.TryGetByLocalEntityId"/> — the
/// transient streaming window where the physics publisher's registration
/// outran the presentation journal's applied projection for the same
/// Create/appearance edge. Retail has no such gap
/// (<c>CEnvCell::init_static_objects</c> installs the CELLARRAY before a
/// static is ever drawable) so a nonzero count here reflects two
/// independent incremental state machines racing during streaming, not
/// steady-state behavior; filed as AD-116, citing the same residency-race
/// reasoning AD-49 already accepts for
/// <see cref="CellTransit.BuildShadowCellSetFromParts"/>'s own outdoor
/// seed. Such an entity
/// 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>
public int UnregisteredStaticRenderFallbackCount { get; private set; }
public int UnregisteredRenderMembershipCount { get; private set; }
/// <summary>Rebuilds the frame's outdoor/shell buckets and clears the
/// per-cell cache. Call once per frame before the driver runs.
/// <summary>Rebuilds the frame's building-shell bucket and clears the
/// per-cell caches. Call once per frame before the driver runs.
/// <paramref name="renderCenterLbX"/>/<paramref name="renderCenterLbY"/>
/// are the streaming recenter origin: record positions are
/// RENDER-ORIGIN-RELATIVE (each landblock's entities carry
/// <c>(lbX CenterX)·192</c> offsets), so mapping a position back to
/// its TRUE landblock byte needs the center added back — the first
/// connected gate of the FW3.2b-2 cutover shipped without this and most
/// outdoor scenery landed in garbage buckets no walk turn ever reads.</summary>
/// were the streaming recenter origin the deleted position-based
/// fallbacks used to convert a render-origin-relative position back to
/// its true landblock byte (<c>LandscapeCellId</c>). Campaign OVERHAUL
/// S2 chunk 5 deleted every position-based fallback — the registry's
/// CELLARRAY is already expressed in exact cell ids — so these two
/// parameters are accepted (unchanged call-site signature) but no
/// longer stored or read.</summary>
internal void BeginFrame(
RenderSceneQuery scene,
uint tupleLandblockId,
int renderCenterLbX,
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;
_tupleLandblockId = tupleLandblockId;
_renderCenterLbX = renderCenterLbX;
_renderCenterLbY = renderCenterLbY;
_cellCache.Clear();
_cellDynamicCache.Clear();
_outdoorMaterialized.Clear();
_outdoorDynamicsMaterialized.Clear();
_shellMaterialized.Clear();
_arenaLength = 0;
UnregisteredStaticRenderFallbackCount = 0;
foreach (List<RenderProjectionRecord> bucket in _indoorByCell.Values)
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();
UnregisteredRenderMembershipCount = 0;
_unregisteredEntitiesThisFrame.Clear();
foreach (List<RenderProjectionRecord> bucket in _shellsByAnchor.Values)
bucket.Clear();
// Retail CEnvCell::init_static_objects does not leave an object solely
// in its authored parent cell. add_obj_to_cell ->
// calc_cross_cells_static -> CPartArray::AddPartsShadow registers all
// visual parts in every crossed cell, including parts with no physics
// BSP. Campaign OVERHAUL S2 chunk 2: that membership is borrowed
// whole from ShadowObjectRegistry's retained retail CELLARRAY — never
// recomputed here.
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);
// Building shells are the landcell BUILDING channel (out of S2):
// retail draws a shell at its own DrawBuilding turn, never through
// the cell's ordinary shadow_part_list walk (Contract C), so this
// is the one sweep that survives chunk 5 — it exists ONLY to bucket
// IsBuildingShell records by their authored anchor, never to
// resolve ordinary static/dynamic cell membership.
int required = _scene.IndexCounts.For(RenderSceneIndex.OutdoorStatic);
if (required > _sweepScratch.Length)
{
_sweepScratch = new RenderProjectionRecord[
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++)
{
ref readonly RenderProjectionRecord record = ref _sweepScratch[i];
if (record.EntityPayload.IsBuildingShell)
{
uint anchor = BuildingShellBucketCellId(in record);
if (!_shellsByAnchor.TryGetValue(anchor, out List<RenderProjectionRecord>? shells))
_shellsByAnchor[anchor] = shells = new List<RenderProjectionRecord>();
shells.Add(record);
if (!record.EntityPayload.IsBuildingShell)
continue;
}
IReadOnlyList<uint> renderCells = ResolveOutdoorStaticRenderCells(
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
// every cell from calc_cross_cells' retained CELLARRAY. The scene's
// 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[
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);
uint anchor = BuildingShellBucketCellId(in record);
if (!_shellsByAnchor.TryGetValue(anchor, out List<RenderProjectionRecord>? shells))
_shellsByAnchor[anchor] = shells = new List<RenderProjectionRecord>();
shells.Add(record);
}
}
/// <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)
/// <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)
{
ArgumentNullException.ThrowIfNull(tryGetRetailCellArray);
ArgumentNullException.ThrowIfNull(getOwnerCells);
(bool found, IReadOnlyList<uint> cells) =
tryGetRetailCellArray(record.Source.LocalEntityId);
if (found)
IReadOnlyList<RetailPartEntry> entries =
_shadows.GetRetailPartEntriesInCell(cellId);
uint previousEntityId = 0;
bool havePrevious = false;
for (int i = 0; i < entries.Count; i++)
{
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)
uint entityId = entries[i].EntityId;
if (havePrevious && entityId == previousEntityId)
continue;
previousEntityId = entityId;
havePrevious = true;
AddToBucket(in record, cellId, buckets);
added = true;
if (_scene.TryGetByLocalEntityId(entityId, out RenderProjectionRecord record)
&& record.Source.SourceId == sourceId)
{
return 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);
return false;
}
public WalkFrameStaticRecords GetCellStatics(uint cellId)
{
if (_cellCache.TryGetValue(cellId, out WalkFrameStaticRecords cached))
return cached;
WalkFrameStaticRecords records =
_indoorByCell.TryGetValue(cellId, out List<RenderProjectionRecord>? bucket)
&& bucket.Count > 0
? new WalkFrameStaticRecords(
AppendToArena(CollectionsMarshal.AsSpan(bucket)), _tupleLandblockId)
: WalkFrameStaticRecords.Empty with { TupleLandblockId = _tupleLandblockId };
WalkFrameStaticRecords records = ResolveCellView(cellId, dynamic: false);
EmitFacilityShadowProbe(records.Records, cellId, "static");
_cellCache[cellId] = records;
return records;
@ -568,19 +256,112 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
{
if (_cellDynamicCache.TryGetValue(cellId, out WalkFrameStaticRecords cached))
return cached;
WalkFrameStaticRecords records =
_indoorDynamicsByCell.TryGetValue(
cellId,
out List<RenderProjectionRecord>? bucket)
&& bucket.Count > 0
? new WalkFrameStaticRecords(
AppendToArena(CollectionsMarshal.AsSpan(bucket)), _tupleLandblockId)
: WalkFrameStaticRecords.Empty with { TupleLandblockId = _tupleLandblockId };
WalkFrameStaticRecords records = ResolveCellView(cellId, dynamic: true);
EmitFacilityShadowProbe(records.Records, cellId, "dynamic");
_cellDynamicCache[cellId] = 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>
/// Facility Hub discriminator for retail's cross-cell render-shadow path.
/// 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)
{
uint anchor = BuildingShellBucketCellId(building);
@ -698,7 +444,7 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
/// <summary>Copies <paramref name="source"/> into <see cref="_arena"/> at
/// its current length, growing the arena first if needed (doubling, or
/// 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
/// leave an earlier-returned segment pointing at a retired backing array
/// — 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)
publication.BspOwnerCount++;
if (entityCylinderCount > 0)

View file

@ -650,6 +650,8 @@ public static class LandblockPhysicsContentBuilder
entity.SourceGfxObjOrSetupId);
if (setup is null)
{
RegisterRenderOnlyStatic(
engine, entity, partArray, landblock, origin);
noCollision++;
continue;
}
@ -710,6 +712,8 @@ public static class LandblockPhysicsContentBuilder
// LiveEntityCollisionBuilder's remarks for the retail anchor.
if (setupShapes.Count == 0)
{
RegisterRenderOnlyStatic(
engine, entity, partArray, landblock, origin);
noCollision++;
continue;
}
@ -744,6 +748,41 @@ public static class LandblockPhysicsContentBuilder
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>(
PreparedCollisionReadResult<T> result,
string kind,

View file

@ -506,6 +506,20 @@ public sealed class ShadowObjectRegistry
RetailCellArrayRoute route,
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))
{
RemoveRetailPartEntriesFromCells(entityId, previousCells);
@ -604,13 +618,71 @@ public sealed class ShadowObjectRegistry
cellIds[i],
out List<RetailPartEntry>? entries))
{
entries.RemoveAll(e => e.EntityId == entityId);
RemoveOwnerPartRows(entries, entityId);
if (entries.Count == 0)
_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>
/// Publishes retail's <c>CPartArray::AddPartsShadow</c> (0x00517e40) rows
/// for one entity: for every cell in <paramref name="orderedCells"/>, in
@ -742,6 +814,12 @@ public sealed class ShadowObjectRegistry
_childPartArrays[childEntityId] = childPartArray;
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;
}
@ -786,6 +864,7 @@ public sealed class ShadowObjectRegistry
if (siblings.Count == 0)
_parentChildren.Remove(parentId);
}
AdvanceMutationRevision(); // see AttachChild (arch F2)
return true;
}
@ -1041,7 +1120,32 @@ public sealed class ShadowObjectRegistry
bool publishMutation = true,
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.
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>
/// Replaces an existing live PartArray collision payload in its current
/// 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)
|| !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;
RegisterMultiPart(
entityId,
@ -1217,7 +1398,10 @@ public sealed class ShadowObjectRegistry
// this replaces the retained part array WITHOUT re-flooding. It
// reuses the CURRENT retail CELLARRAY (if any) and only rewrites
// 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;
if (_retailCellArrays.TryGetValue(entityId, out List<uint>? retailCells)
@ -2270,6 +2454,37 @@ public sealed class ShadowObjectRegistry
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
// (pc:283540) reached its terminal case — no retained collision
// cells at all to republish from — so BOTH products stay exactly as
@ -2285,6 +2500,8 @@ public sealed class ShadowObjectRegistry
BumpOwnerVersion(entityId);
}
private readonly uint[] _singleCellScratch = new uint[1];
private void ReplacePositionRows(
uint entityId,
RegistrationRecord registration,
@ -2326,12 +2543,12 @@ public sealed class ShadowObjectRegistry
exactCells.Add(cellId);
}
if (registration.IsMultiPart
&& _entityShapes.TryGetValue(
entityId,
out IReadOnlyList<ShadowShape>? shapes))
IReadOnlyList<ShadowShape>? shapes = null;
bool isMultiPartDispatch = registration.IsMultiPart
&& _entityShapes.TryGetValue(entityId, out shapes);
if (isMultiPartDispatch)
{
foreach (ShadowShape shape in shapes)
foreach (ShadowShape shape in shapes!)
{
Vector3 partWorldPosition = worldPosition
+ Vector3.Transform(shape.LocalPosition, worldRotation);
@ -2371,7 +2588,15 @@ public sealed class ShadowObjectRegistry
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);
else
_entityToCells[entityId] = exactCells;
@ -2414,11 +2639,26 @@ public sealed class ShadowObjectRegistry
foreach (uint cellId in cellIds)
{
if (_cells.TryGetValue(cellId, out var list))
list.RemoveAll(entry => entry.EntityId == entityId);
RemoveOwnerRows(list, 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);
BumpOwnerVersion(entityId);
return true;
@ -2502,6 +2742,19 @@ public sealed class ShadowObjectRegistry
_withdrawnPrefixesByOwner.TryGetValue(
entityId,
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
&& _entityShapes.TryGetValue(entityId, out var shapes))
{
@ -2517,7 +2770,8 @@ public sealed class ShadowObjectRegistry
lbPrefix,
reg.SeedCellId,
reg.IsStatic,
publishMutation: false);
publishMutation: false,
partArray: retainedPartArray);
}
else
{
@ -2537,7 +2791,8 @@ public sealed class ShadowObjectRegistry
reg.Flags,
reg.SeedCellId,
reg.IsStatic,
publishMutation: false);
publishMutation: false,
partArray: retainedPartArray);
}
// Register is also the authoritative movement/replacement API and
@ -2870,10 +3125,69 @@ public sealed class ShadowObjectRegistry
_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)
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>
/// Retires one logical owner's rows from a streamed-out prefix. This is
/// the owner-granular form used by the collision-generation retirement
@ -2895,10 +3209,39 @@ public sealed class ShadowObjectRegistry
AdvanceMutationRevision();
return;
}
if (!_entityToCells.TryGetValue(entityId, out List<uint>? cells))
return;
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--)
{
uint cellId = cells[index];
@ -3311,8 +3654,7 @@ public sealed class ShadowObjectRegistry
{
rows.Add(new PreparedShadowCellRows(
cellId,
entries.Where(entry => entry.EntityId == entityId)
.ToArray()));
CollectOwnerRows(entries, entityId)));
}
}
}
@ -3341,8 +3683,7 @@ public sealed class ShadowObjectRegistry
{
retailRows.Add(new PreparedShadowRetailPartRows(
cellId,
entries.Where(entry => entry.EntityId == entityId)
.ToArray()));
CollectOwnerPartRows(entries, entityId)));
}
}
}

View file

@ -220,19 +220,15 @@ internal sealed class LiveEntityCollisionBuilder
effectivePartGfxObjIds: effectivePartGfxObjIds,
physicsBspBounds: _physicsBspBounds);
if (shapes.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;
// 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.
// Campaign OVERHAUL S2 chunk 5 (item B): retail's WHOLE visual part
// array — every Setup part, colliding or not — computed BEFORE the
// empty-shapes gate below. A live entity whose Setup carries visual
// parts but no collision geometry at all (a short-lived spell/visual
// effect object) must still register for render: Contract B's
// CPartArray::AddPartsShadow membership is not gated on a successful
// collision-shape dispatch, only on a non-null part array. Distinct
// from `shapes`, which stays the BSP-exclusive COLLISION dispatch
// (AP-152).
IReadOnlyList<ShadowShape> renderParts = ShadowShapeBuilder.FromSetupRenderParts(
setup,
scale,
@ -241,6 +237,21 @@ internal sealed class LiveEntityCollisionBuilder
_getGfxObj,
_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(
entity.Id,
entity.SourceGfxObjOrSetupId,

View file

@ -155,6 +155,78 @@ public sealed class LiveEntityCollisionBuilderTests
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>
/// 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

View file

@ -520,13 +520,6 @@ public sealed class ArchRenderSceneTests
LightCandidate: 1,
Dirty: 6),
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);
}
@ -580,7 +573,6 @@ public sealed class ArchRenderSceneTests
Assert.Equal(1, updated.IndexCounts.Translucent);
Assert.Equal(1, updated.IndexCounts.LightCandidate);
Assert.Equal(1, updated.IndexCounts.Dirty);
Assert.Equal(1, updated.GetCellDynamicCount(indoorCell));
RenderProjectionRecord replacement = rebucketed with
{
@ -592,7 +584,6 @@ public sealed class ArchRenderSceneTests
RenderSceneQuery replaced = scene.OpenQuery();
Assert.Equal(0, replaced.IndexCounts.Dynamic);
Assert.Equal(1, replaced.IndexCounts.IndoorCellStatic);
Assert.Equal(1, replaced.GetCellStaticCount(indoorCell));
scene.Apply(
[
@ -1085,6 +1076,92 @@ public sealed class ArchRenderSceneTests
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(
ulong id,
ulong incarnation,

View file

@ -103,7 +103,6 @@ public sealed class DirectionalShadowCasterFrameTests
Assert.Equal(
[RenderSceneIndex.OutdoorStatic, RenderSceneIndex.OutdoorDynamic],
source.CopiedIndices);
Assert.Equal(0, source.CellQueries);
Assert.Equal(2, frame.Stats.IndexCopies);
}
@ -635,7 +634,6 @@ public sealed class DirectionalShadowCasterFrameTests
public int IndexCountReads { get; private set; }
public int IndexCopies { get; private set; }
public int CellQueries { get; private set; }
public int ProjectionReads { get; private set; }
public int BatchedProjectionCopies { get; private set; }
public ulong TopologyRevision { get; private set; } = 1;
@ -731,6 +729,32 @@ public sealed class DirectionalShadowCasterFrameTests
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(
RenderSceneGeneration generation,
ReadOnlySpan<RenderProjectionId> ids,
@ -791,25 +815,6 @@ public sealed class DirectionalShadowCasterFrameTests
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(
RenderProjectionRecord[] values,

View file

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

View file

@ -3,9 +3,23 @@ using AcDream.App.Rendering.Scene;
using AcDream.App.Rendering.Scene.Arch;
using AcDream.App.Rendering.Walk;
using AcDream.Core.Physics;
using AcDream.Core.World;
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
{
[Fact]
@ -79,288 +93,6 @@ public sealed class WalkProductionWorldDataTests
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) =>
ShadowShape.Bsp(
gfxObjId,
@ -388,20 +120,54 @@ public sealed class WalkProductionWorldDataTests
SourceId = sourceId,
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]
public void BeginFrame_IndoorStatic_RegisteredEntityUsesRegistryRetailCellArray()
{
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
// indoor sweep actually consulted the registry rather than falling
// back to the authored parent.
const uint decoyParentCellId = 0x8A0201C1u;
private static RenderProjectionRecord DynamicRecord(
uint entityId, uint sourceId, uint parentCellId) =>
IndoorStaticRecord(entityId, sourceId, parentCellId) with
{
Id = RenderProjectionId.FromRaw(0x0100_0000_0000_0000u | entityId),
ProjectionClass = RenderProjectionClass.LiveDynamicRoot,
};
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) };
shadows.RegisterMultiPart(
entityId,
@ -412,10 +178,26 @@ public sealed class WalkProductionWorldDataTests
flags: EntityCollisionFlags.None,
worldOffsetX: 0f,
worldOffsetY: 0f,
landblockId: 0x8A020000u,
seedCellId: retailCellId,
landblockId: landblockId,
seedCellId: seedCellId,
isStatic: true,
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.Equal(new[] { retailCellId }, retailCells);
@ -435,34 +217,265 @@ public sealed class WalkProductionWorldDataTests
Assert.DoesNotContain(
worldData.GetCellStatics(decoyParentCellId).Records,
record => record.Id == projection.Id);
Assert.Equal(0, worldData.UnregisteredStaticRenderFallbackCount);
Assert.Equal(0, worldData.UnregisteredRenderMembershipCount);
}
[Fact]
public void BeginFrame_IndoorStatic_UnregisteredEntityFallsBackToParentCellAndCountsFallback()
public void GetCellStatics_RegistryAheadOfSceneContributesToNoCellAndCountsFallback()
{
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;
// Bare, empty registry: entityId is never registered, so
// TryGetRetailCellArray must answer false for it.
var shadows = new ShadowObjectRegistry();
Assert.False(shadows.TryGetRetailCellArray(entityId, out _));
RenderSceneGeneration generation = RenderSceneGeneration.FromRaw(1);
using var scene = new ArchRenderScene(generation);
RenderProjectionRecord projection =
IndoorStaticRecord(entityId, sourceId: 0x02000002u, parentCellId);
IndoorStaticRecord(entityId, sourceId: 0x02000003u, parentCellId);
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);
worldData.BeginFrame(
scene.OpenQuery(), 0x8A020000u, renderCenterLbX: 0x8A, renderCenterLbY: 0x02);
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);
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) =>

View file

@ -1333,6 +1333,73 @@ public sealed class LandblockPhysicsPublisherTests
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()
{
Flags = DatReaderWriter.Enums.GfxObjFlags.HasPhysics,

View file

@ -108,4 +108,77 @@ public sealed class LandblockPhysicsContentBuilderStaticSphereTests
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.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));
}
}