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:
parent
f30039d3d9
commit
c94a1a407e
20 changed files with 2141 additions and 1026 deletions
|
|
@ -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
|
|
@ -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 1–3; bake-tool 4
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -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, R5–R7 | live list handed out; probe-flag coupling; unloaded-neighbour rows; circumsphere cheap reject; particle branch | documented (AD-117 items 2–4; 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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
|
|
|
|||
|
|
@ -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 & 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>>= 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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue