feat(physics): S2 chunk 3 - one flood per registration feeds collision rows and render entries
ShadowObjectRegistry computes the CELLARRAY once per registration under Contract A (cylsphere route from the Setup's collision cylspheres, else the bbox route over the whole visual part array) and derives BOTH products from it: the collision rows (_entityToCells/_cells, CShadowObj per cell via add_shadows_to_cells @0x00514ae0) and the per-part render entries (AddPartsShadow @0x00517e40). The second, independent collision flood is gone. A caller that supplies no part array floods from its collision shapes exactly as before, so every legacy expectation holds byte-for-byte. The staged SetPosition pipeline now carries the retail part array, cell array, route, and entries through TryCaptureOwnerState/InstallOwnerState and publishes them beside the collision cell replacements, honoring the keep-when-empty rule (SetPositionInternal num_cells gate, pc:283540) for both products together; two new tests pin a cross-cell move and the keep-when-empty case. Behavior change, retail-exact: an object with decorative non-BSP parts now has its collision shapes registered in every cell those parts reach (pinned by a two-cell fixture); all-BSP objects are unchanged. Movement paths still take collision cells from the transition and recompute the retail product separately; chunk 4 unifies them on the transition's array as retail does. No particle emitter reaches this registry. Gates (implementer's isolated worktree at identical content): Release build 0/0; Core 4,961/4,961; App hermetic 6,760/6,760; collision/InstalledDat fixtures 63/63; Runtime 1,884/1,884; Content 213/213. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
d79058cec4
commit
afbd241016
4 changed files with 665 additions and 85 deletions
|
|
@ -177,27 +177,35 @@ draws its complete constructed shell. Portal polygons are not GPU mesh clips;
|
||||||
pixel occlusion comes from the ordered walk and depth buffer.
|
pixel occlusion comes from the ordered walk and depth buffer.
|
||||||
|
|
||||||
**Retail static render-shadow cell membership (2026-08-31; ownership moved
|
**Retail static render-shadow cell membership (2026-08-31; ownership moved
|
||||||
to `ShadowObjectRegistry` by Campaign OVERHAUL S2 chunk 2).** Indoor statics
|
to `ShadowObjectRegistry` by Campaign OVERHAUL S2 chunk 2; collision cutover
|
||||||
are not rendered solely from their authored parent EnvCell. Retail's
|
by S2 chunk 3, 2026-09-02).** Indoor statics are not rendered solely from
|
||||||
`CEnvCell::init_static_objects` creates a `CPhysicsObj` for every static,
|
their authored parent EnvCell. Retail's `CEnvCell::init_static_objects`
|
||||||
then `calc_cross_cells_static` + `CPartArray::AddPartsShadow` places every
|
creates a `CPhysicsObj` for every static, then `calc_cross_cells_static` +
|
||||||
visual part in each crossed cell's `shadow_part_list`, including decorative
|
`CPartArray::AddPartsShadow` places every visual part in each crossed cell's
|
||||||
GfxObjs that have no collision BSP. `ShadowObjectRegistry` owns this product
|
`shadow_part_list`, including decorative GfxObjs that have no collision BSP —
|
||||||
as a retained per-entity retail CELLARRAY (`TryGetRetailCellArray`) plus
|
one CELLARRAY flood feeds BOTH the collision `shadow_object_list` and the
|
||||||
per-cell part entries (`GetRetailPartEntriesInCell`), computed once at the
|
render `shadow_part_list` (Contract B). `ShadowObjectRegistry` now mirrors
|
||||||
SAME registration transaction as the collision flood
|
this exactly: `RegisterMultiPart`/`Register`'s optional `partArray:`
|
||||||
(`RegisterMultiPart`/`Register`'s optional `partArray:` parameter) — not
|
parameter, when supplied (every production call site since chunk 1b), drives
|
||||||
rebuilt per frame. `WalkProductionWorldData`'s indoor and outdoor static
|
ONE Contract A flood (`ComputeContractACellArray`) whose result becomes BOTH
|
||||||
sweeps BORROW that array whole per record; they no longer recompute
|
`GetOwnerCells`/`_entityToCells`/`_cells` (collision) and the retained
|
||||||
membership from each MeshRef's prepared visual AABB (the deleted
|
per-entity retail CELLARRAY (`TryGetRetailCellArray`) plus per-cell part
|
||||||
`ResolveStaticRenderCells` / `ShadowObjectRegistry.ComputeStaticRenderCells`
|
entries (`GetRetailPartEntriesInCell`) — not two independent floods, and not
|
||||||
pair). The retail CELLARRAY remains distinct from `ShadowObjectRegistry`'s
|
rebuilt per frame. A caller that supplies no part array (only test/legacy
|
||||||
own collision rows (`GetOwnerCells`/`_entityToCells`) — the two retail lists
|
call sites remain) keeps the pre-chunk-3 collision-only dispatch untouched.
|
||||||
share transit math but have different membership (collision cutover to the
|
`WalkProductionWorldData`'s indoor and outdoor static sweeps BORROW the
|
||||||
same CELLARRAY is S2 chunk 3) — until then, a record whose registry entry has
|
retail array whole per record; they no longer recompute membership from each
|
||||||
no retail array yet (a streaming-window race between the static-projection
|
MeshRef's prepared visual AABB (the deleted `ResolveStaticRenderCells` /
|
||||||
journal and the physics publisher) falls back to its authored parent cell
|
`ShadowObjectRegistry.ComputeStaticRenderCells` pair). A structural
|
||||||
alone, counted by `WalkProductionWorldData.UnregisteredStaticRenderFallbackCount`.
|
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
|
||||||
|
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`.
|
||||||
A primitive-only Setup may reuse its already-authored cylsphere collision
|
A primitive-only Setup may reuse its already-authored cylsphere collision
|
||||||
cell set; BSP-bearing and pure-visual statics use the all-visual-part box
|
cell set; BSP-bearing and pure-visual statics use the all-visual-part box
|
||||||
walk. The Facility Hub stair Setup `0x02000623` is the installed-DAT
|
walk. The Facility Hub stair Setup `0x02000623` is the installed-DAT
|
||||||
|
|
|
||||||
|
|
@ -36,9 +36,10 @@ One transaction per object (`CPhysicsObj::calc_cross_cells_static`
|
||||||
|
|
||||||
| Owner | Inputs | Output | Runs | Consumers |
|
| Owner | Inputs | Output | Runs | Consumers |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| `LandblockPhysicsPublisher.PublishStaticEntity` → `ShadowObjectRegistry.RegisterMultiPart` (`LandblockPhysicsPublisher.cs:930-1079`, `ShadowObjectRegistry.cs:493-584`) | BSP parts only (`ShadowShapeBuilder.FromLandblockBspParts`) or Setup cyl/sphere fallback; `CellTransit.BuildShadowCellSetFromParts` | `_entityToCells[id]` (ordered, the CELLARRAY analog) + `_cells[cell]` `ShadowEntry` rows | once per static at publication | physics broadphase `TransitionTypes.FindObjCollisionsInCell` (`TransitionTypes.cs:3755-3779`); outdoor static render lookup reads it verbatim |
|
| **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, computed once at registration | 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` |
|
| **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` |
|
||||||
| `LiveEntityCollisionBuilder.Register` / `ShadowObjectRegistry.UpdatePosition` / `CommitSetPosition` (`LiveEntityCollisionBuilder.cs:149-280`, `ShadowObjectRegistry.cs:881-982`) | `ShadowShapeBuilder.FromSetup` (BSP parts, else cylspheres, else spheres) | `_entityToCells` + `_cells` | spawn and every accepted move/appearance change | physics broadphase; dynamic render lookup reads it verbatim |
|
| **Chunk 3 (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. `CommitSetPosition`'s `None`/`Preserve`/`Replace` actions (`RefreshPositionRows`/`ReplacePositionRows`) are UNCHANGED — they consume an externally-decided collision cell list and separately recompute the retail product via the same Contract A primitive on every move (chunk 1's design, not a second registration flood) | `_entityToCells` + `_cells`, and (when a part array is retained) `_retailCellArrays`/`_retailPartEntriesByCell` | spawn and every accepted move/appearance change | physics broadphase; `WalkProductionWorldData` dynamic render lookup |
|
||||||
|
| **Chunk 3 (2026-09-02).** The staged `ShadowObjectRegistry.TryPrepareSetPosition`/`TryApplySetPosition` pipeline | `PreparedShadowOwnerState` now also carries `RetailPartArray`/`RetailCellIds`/`RetailRoute`/`RetailRows`; `TryCaptureOwnerState`/`InstallOwnerState` seed them so the staging registry's own `RecomputeRetailCellArrayIfPresent` call (reached through `CommitSetPosition`) no longer silently no-ops; `PrepareRetailPartEntryReplacements` diffs the staged retail rows the same way `PrepareCellReplacements` diffs collision rows | `_retailPartEntriesByCell` publishes alongside `_cells` on `TryApplySetPosition` | every live entity moving through the transactional SetPosition publication tail (Runtime) | same as direct `CommitSetPosition`, now consistent with it |
|
||||||
| `WalkProductionWorldData.ResolveDynamicRenderCells` (`WalkProductionWorldData.cs:247-283`) | `GetOwnerCells`; for an `EquippedChild` with no cells, a 64-hop parent-chain walk | whole-record buckets | every `BeginFrame` | `WalkFrameDriver.GetCellDynamics` |
|
| `WalkProductionWorldData.ResolveDynamicRenderCells` (`WalkProductionWorldData.cs:247-283`) | `GetOwnerCells`; for an `EquippedChild` with no cells, a 64-hop parent-chain walk | whole-record buckets | every `BeginFrame` | `WalkFrameDriver.GetCellDynamics` |
|
||||||
| `LightManager.SelectForCell` (`LightManager.cs:594-650`) | flat `_all` list, sphere overlap | per-cell light subset | per cell drawn | lighting (S5 owns; retail selects from the current EnvCell + stab list) |
|
| `LightManager.SelectForCell` (`LightManager.cs:594-650`) | flat `_all` list, sphere overlap | per-cell light subset | per cell drawn | lighting (S5 owns; retail selects from the current EnvCell + stab list) |
|
||||||
| `DirectionalShadowCasterFrame` (`DirectionalShadowCasterFrame.cs:229-233`) | flat outdoor indices, no cell filter | caster list | per frame | shadow pass (S5) |
|
| `DirectionalShadowCasterFrame` (`DirectionalShadowCasterFrame.cs:229-233`) | flat outdoor indices, no cell filter | caster list | per frame | shadow pass (S5) |
|
||||||
|
|
@ -49,7 +50,8 @@ Findings that shape the chunks:
|
||||||
- The two static floods are the SAME `CellTransit` walk with DIFFERENT part
|
- The two static floods are the SAME `CellTransit` walk with DIFFERENT part
|
||||||
inputs (BSP-only vs all visual parts). Retail uses all parts once. The
|
inputs (BSP-only vs all visual parts). Retail uses all parts once. The
|
||||||
render-side flood already carries retail's inputs; the collision side does
|
render-side flood already carries retail's inputs; the collision side does
|
||||||
not.
|
not. **RESOLVED (chunk 3):** one Contract A flood now drives both, for every
|
||||||
|
registration that carries a real part array.
|
||||||
- Render membership is whole-object per cell; retail's is every part per
|
- Render membership is whole-object per cell; retail's is every part per
|
||||||
cell (identity retained per part, with cell frame and clip planes).
|
cell (identity retained per part, with cell frame and clip planes).
|
||||||
- Children are inherited only on the render side, at bucket time; retail
|
- Children are inherited only on the render side, at bucket time; retail
|
||||||
|
|
@ -237,3 +239,92 @@ from its centered position. The scan was corrected to key on
|
||||||
a different Neftet-landblock GfxObj (`0x010046EB`) genuinely near the block's
|
a different Neftet-landblock GfxObj (`0x010046EB`) genuinely near the block's
|
||||||
Y=192 edge, producing 2 of 6 retail cells in the neighboring landblock
|
Y=192 edge, producing 2 of 6 retail cells in the neighboring landblock
|
||||||
`0x8765`.
|
`0x8765`.
|
||||||
|
|
||||||
|
## 6. Chunk 3 evidence (2026-09-02)
|
||||||
|
|
||||||
|
Chunk 3 deletes the second flood chunk 1 left running beside the collision
|
||||||
|
one. `RegisterMultiPart`/`Register` now compute the CELLARRAY exactly once
|
||||||
|
via the shared `ComputeContractACellArray` primitive (Contract A: cylsphere
|
||||||
|
route from the collision shapes' cylinders, else the bbox route over the
|
||||||
|
part array) whenever a caller supplies a real part array — production always
|
||||||
|
does, since chunk 1b threaded `partArray:` into every `RegisterMultiPart`
|
||||||
|
call site. `_entityToCells`/`_cells` (collision, built from the actual
|
||||||
|
collision shapes) and `_retailCellArrays`/`_retailPartEntriesByCell` (render,
|
||||||
|
built from the part array) both read off that one array via
|
||||||
|
`PublishRetailCellArray`. A caller with no part array (every existing unit
|
||||||
|
test, and any future legacy caller) keeps the exact pre-chunk-3 dispatch —
|
||||||
|
`partArray ?? shapes` degrades to the old hasBsp-branch flood over `shapes`
|
||||||
|
alone — so no existing expectation for such a caller changed; the full
|
||||||
|
`Lane!=Timing&Lane!=Linux` Core suite (4,957 tests before chunk 3, 4,961
|
||||||
|
after adding chunk-3 coverage) needed zero test-expectation edits.
|
||||||
|
|
||||||
|
**Behavior change, exactly as predicted in §5:** an object whose part array
|
||||||
|
mixes a colliding BSP part with a decorative non-BSP-colliding visual part
|
||||||
|
now gets a `ShadowEntry` collision row for its BSP part in EVERY cell the
|
||||||
|
CELLARRAY reaches — including cells the BSP part does not geometrically
|
||||||
|
occupy, because membership (which cells hold a row at all) is decided by the
|
||||||
|
whole-array flood (Contract A), not per part. The BSP polygon test still
|
||||||
|
decides actual contact at query time (`TransitionTypes.FindObjCollisionsInCell`
|
||||||
|
never changed); only the candidate set widens. Objects whose part array is
|
||||||
|
entirely BSP-bearing are UNCHANGED (chunk 1 evidence: the bbox route over the
|
||||||
|
collision shapes and the bbox route over the identical part array agree
|
||||||
|
exactly, so old-collision and new-collision compute the same set either way)
|
||||||
|
— `tests/AcDream.App.Tests/Physics/RetailCellArrayComparatorInstalledDatTests.cs`
|
||||||
|
still reports retail == collision for all five installed-DAT fixtures, now as
|
||||||
|
a structural guarantee (both read the same array) rather than an incidental
|
||||||
|
agreement. The synthetic pin for the mixed case is
|
||||||
|
`ShadowObjectRegistryRetailCellArrayTests.RegisterMultiPart_DecorativePartArrayEntry_WidensCollisionRowsToo`.
|
||||||
|
|
||||||
|
**`b3b7d922` prune removal, re-verified:** the bbox route
|
||||||
|
(`CellTransit.BuildShadowCellSetFromParts`) still carries no post-flood prune
|
||||||
|
of outside cells, matching Contract A's bbox branch exactly (`§1` above: "the
|
||||||
|
outdoor expansion... no later rule that removes the outside cells it just
|
||||||
|
added"). Chunk 3 does not reintroduce one; the collision cut over inherits
|
||||||
|
the same unpruned array the render side already used.
|
||||||
|
|
||||||
|
**The staged pipeline (item B, the chunk-1 flagged gap):** `TryPrepareSetPosition`
|
||||||
|
built a scratch `staging` registry via `InstallOwnerState`, then ran the
|
||||||
|
CommitSetPosition suffix on it — but `InstallOwnerState` never seeded
|
||||||
|
`_entityRetailPartArrays`/`_retailCellArrays`/`_retailCellArrayRoutes`/
|
||||||
|
`_retailPartEntriesByCell`, so staging's own internal
|
||||||
|
`RecomputeRetailCellArrayIfPresent` call (reached through
|
||||||
|
`RefreshPositionRows`/`ReplacePositionRows`) found nothing retained and
|
||||||
|
no-opped every time — the retail product silently never traveled through a
|
||||||
|
live-entity SetPosition commit. `PreparedShadowOwnerState` now also carries
|
||||||
|
`RetailPartArray`/`RetailCellIds`/`RetailRoute`/`RetailRows`;
|
||||||
|
`TryCaptureOwnerState` captures them from the live registry,
|
||||||
|
`InstallOwnerState` seeds them into staging, and a new
|
||||||
|
`PrepareRetailPartEntryReplacements` (parallel to `PrepareCellReplacements`)
|
||||||
|
diffs the staged retail rows against the live `_retailPartEntriesByCell` so
|
||||||
|
`TryApplySetPosition` can publish them alongside the collision cell
|
||||||
|
replacements it already applies. `LandblockReplacementBuilder`,
|
||||||
|
`MirrorOwnerFrom`, and `RefreshRetainedOwnerFrom` all route through the same
|
||||||
|
`TryCaptureOwnerState`/`InstallOwnerState` pair, so they inherit the fix for
|
||||||
|
free without their own changes. Pinned by
|
||||||
|
`ShadowObjectRegistryRetailCellArrayTests.StagedSetPosition_WithRetainedPartArray_MovesBothProductsTogether`
|
||||||
|
and `..._KeepWhenEmpty_PreservesBothProductsTogether`.
|
||||||
|
|
||||||
|
**Scope decision — movement (`RefreshPositionRows`/`ReplacePositionRows`) is
|
||||||
|
NOT unified with collision's cell list.** Item A's title is "one flood per
|
||||||
|
REGISTRATION"; `ReplacePositionRows` receives an externally-decided collision
|
||||||
|
cell list from the Transition/physics engine (a different subsystem, not
|
||||||
|
`ComputeContractACellArray`) and, unchanged since chunk 1, separately
|
||||||
|
recomputes the retail product via `RecomputeRetailCellArrayIfPresent` on
|
||||||
|
every move. This is not the "two floods disagreeing over the same input"
|
||||||
|
problem chunk 3 targets (registration, where BOTH products used to flood the
|
||||||
|
SAME `shapes`/`partArray` independently) — the transition-computed cross-cell
|
||||||
|
list and Contract A's part-array flood are legitimately different
|
||||||
|
computations over different inputs, and collapsing them into one would be an
|
||||||
|
architecture change beyond this chunk's contract. Flagged here as an open
|
||||||
|
question for the chunk 3 retail review, not resolved unilaterally.
|
||||||
|
|
||||||
|
**Particles (item D):** no production code path sets the `PARTICLE_EMITTER_PS`
|
||||||
|
state bit (`0x1000`) when calling `Register`/`RegisterMultiPart` today, and
|
||||||
|
particle emitters do not register with `ShadowObjectRegistry` at all —
|
||||||
|
`AcDream.App/Rendering/ParticleRenderer.cs` owns their placement independently
|
||||||
|
(`_meshReferences.Register(emitter.Handle, gfxObjId)`, a GPU mesh-reference
|
||||||
|
table, not a shadow-cell registration). There is no `(state & 0x1000) != 0`
|
||||||
|
branch to implement in this registry today; the retail
|
||||||
|
`add_particle_shadow_to_cell` own-cell-only rule remains chunk 4 (or later)
|
||||||
|
territory, to be implemented if/when particle emitters are ever routed
|
||||||
|
through this registry.
|
||||||
|
|
|
||||||
|
|
@ -450,7 +450,36 @@ public sealed class ShadowObjectRegistry
|
||||||
return;
|
return;
|
||||||
|
|
||||||
_entityRetailPartArrays[entityId] = partArray;
|
_entityRetailPartArrays[entityId] = partArray;
|
||||||
|
(IReadOnlyList<uint> cellArray, RetailCellArrayRoute route) = ComputeContractACellArray(
|
||||||
|
seedCellId, entityWorldPos, entityWorldRot, state, collisionShapes, partArray, isStatic);
|
||||||
|
PublishRetailCellArray(entityId, cellArray, route, partArray);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Campaign OVERHAUL S2 chunk 3: retail's <c>calc_cross_cells_static</c>
|
||||||
|
/// (0x00515160) dispatch + flood, PURE — no registry state is read or
|
||||||
|
/// written. Cylsphere route
|
||||||
|
/// (<c>CObjCell::find_cell_list</c> 0x0052b9f0) when the cached
|
||||||
|
/// <c>HAS_PHYSICS_BSP_PS</c> state bit (0x10000) is clear AND
|
||||||
|
/// <paramref name="collisionShapes"/> carries at least one Cylinder shape
|
||||||
|
/// (Contract A's <c>GetNumCylsphere() != 0</c>); otherwise the bbox route
|
||||||
|
/// (<c>CPhysicsObj::find_bbox_cell_list</c> 0x00510fc0) over
|
||||||
|
/// <paramref name="partArray"/>. Chunk 3 calls this ONCE per registration
|
||||||
|
/// and uses the SAME result for both the collision CELLARRAY
|
||||||
|
/// (<c>_entityToCells</c>/<c>_cells</c>) and the retail render product
|
||||||
|
/// (<c>_retailCellArrays</c>/<c>_retailPartEntriesByCell</c>) — see
|
||||||
|
/// <see cref="RegisterMultiPart"/> and <see cref="Register"/> — instead of
|
||||||
|
/// each product running its own independent flood.
|
||||||
|
/// </summary>
|
||||||
|
private (IReadOnlyList<uint> Cells, RetailCellArrayRoute Route) ComputeContractACellArray(
|
||||||
|
uint seedCellId,
|
||||||
|
Vector3 worldPos,
|
||||||
|
Quaternion worldRot,
|
||||||
|
uint state,
|
||||||
|
IReadOnlyList<ShadowShape> collisionShapes,
|
||||||
|
IReadOnlyList<ShadowShape> partArray,
|
||||||
|
bool isStatic)
|
||||||
|
{
|
||||||
// Contract A: GetNumCylsphere() reads the Setup's authored CylSpheres,
|
// Contract A: GetNumCylsphere() reads the Setup's authored CylSpheres,
|
||||||
// which acdream carries as the Cylinder shapes of the COLLISION
|
// which acdream carries as the Cylinder shapes of the COLLISION
|
||||||
// dispatch — never as visual parts.
|
// dispatch — never as visual parts.
|
||||||
|
|
@ -465,30 +494,47 @@ public sealed class ShadowObjectRegistry
|
||||||
}
|
}
|
||||||
bool cylsphereRoute = (state & 0x10000u) == 0u && hasCylsphere;
|
bool cylsphereRoute = (state & 0x10000u) == 0u && hasCylsphere;
|
||||||
|
|
||||||
IReadOnlyList<uint> cellArray;
|
|
||||||
RetailCellArrayRoute route;
|
|
||||||
if (cylsphereRoute)
|
if (cylsphereRoute)
|
||||||
{
|
{
|
||||||
route = RetailCellArrayRoute.Cylsphere;
|
|
||||||
List<DatReaderWriter.Types.Sphere> cylSpheres =
|
List<DatReaderWriter.Types.Sphere> cylSpheres =
|
||||||
BuildFloodSpheres(entityWorldPos, entityWorldRot, collisionShapes);
|
BuildFloodSpheres(worldPos, worldRot, collisionShapes);
|
||||||
cellArray = CellTransit.BuildShadowCellSet(
|
IReadOnlyList<uint> cells = CellTransit.BuildShadowCellSet(
|
||||||
FloodCache, seedCellId, cylSpheres, cylSpheres.Count, isStatic);
|
FloodCache, seedCellId, cylSpheres, cylSpheres.Count, isStatic);
|
||||||
|
return (cells, RetailCellArrayRoute.Cylsphere);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
route = RetailCellArrayRoute.BoundingBox;
|
|
||||||
List<ShadowPartBox> boxes =
|
List<ShadowPartBox> boxes =
|
||||||
BuildFloodPartBoxes(entityWorldPos, entityWorldRot, partArray);
|
BuildFloodPartBoxes(worldPos, worldRot, partArray);
|
||||||
List<DatReaderWriter.Types.Sphere> spheres =
|
List<DatReaderWriter.Types.Sphere> spheres =
|
||||||
BuildBspPartSpheres(entityWorldPos, entityWorldRot, partArray);
|
BuildBspPartSpheres(worldPos, worldRot, partArray);
|
||||||
cellArray = CellTransit.BuildShadowCellSetFromParts(
|
IReadOnlyList<uint> cells = CellTransit.BuildShadowCellSetFromParts(
|
||||||
FloodCache, seedCellId, boxes, spheres, isStatic);
|
FloodCache, seedCellId, boxes, spheres, isStatic);
|
||||||
|
return (cells, RetailCellArrayRoute.BoundingBox);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The route is recorded even when the flood came back empty — the
|
/// <summary>
|
||||||
// dispatch decision was still made (mirrors GetRetailCellArrayRoute
|
/// Publishes the retail render product for <paramref name="entityId"/>
|
||||||
// being independently observable from cell-list non-emptiness).
|
/// from an ALREADY-COMPUTED cell array (<see cref="ComputeContractACellArray"/>
|
||||||
|
/// or a caller's own equivalent flood) — the second half of chunk 3's
|
||||||
|
/// "one flood, two products" registration recipe. Clears any previously
|
||||||
|
/// published rows for this entity first, mirroring
|
||||||
|
/// <c>remove_shadows_from_cells</c> symmetry for this side product. The
|
||||||
|
/// route is recorded even when <paramref name="cellArray"/> is empty —
|
||||||
|
/// the dispatch decision was still made.
|
||||||
|
/// </summary>
|
||||||
|
private void PublishRetailCellArray(
|
||||||
|
uint entityId,
|
||||||
|
IReadOnlyList<uint> cellArray,
|
||||||
|
RetailCellArrayRoute route,
|
||||||
|
IReadOnlyList<ShadowShape> partArray)
|
||||||
|
{
|
||||||
|
if (_retailCellArrays.TryGetValue(entityId, out List<uint>? previousCells))
|
||||||
|
{
|
||||||
|
RemoveRetailPartEntriesFromCells(entityId, previousCells);
|
||||||
|
_retailCellArrays.Remove(entityId);
|
||||||
|
}
|
||||||
_retailCellArrayRoutes[entityId] = route;
|
_retailCellArrayRoutes[entityId] = route;
|
||||||
if (cellArray.Count == 0)
|
if (cellArray.Count == 0)
|
||||||
return;
|
return;
|
||||||
|
|
@ -685,12 +731,39 @@ public sealed class ShadowObjectRegistry
|
||||||
: DeriveOutdoorSeed(worldPos, worldOffsetX, worldOffsetY, landblockId);
|
: DeriveOutdoorSeed(worldPos, worldOffsetX, worldOffsetY, landblockId);
|
||||||
if (seed == 0u) return;
|
if (seed == 0u) return;
|
||||||
|
|
||||||
|
bool hasRetailPartArray = partArray is not null && partArray.Count != 0;
|
||||||
|
|
||||||
|
IReadOnlyList<uint> cellSet;
|
||||||
|
RetailCellArrayRoute retailRoute = RetailCellArrayRoute.None;
|
||||||
|
if (hasRetailPartArray)
|
||||||
|
{
|
||||||
|
// Campaign OVERHAUL S2 chunk 3: ONE flood, computed via Contract A
|
||||||
|
// (calc_cross_cells_static 0x00515160), drives BOTH the collision
|
||||||
|
// CELLARRAY and the retail render product — replacing the
|
||||||
|
// separate legacy flood below AND the second flood
|
||||||
|
// RecomputeRetailCellArray used to run independently.
|
||||||
|
IReadOnlyList<ShadowShape> collisionShapes =
|
||||||
|
collisionType == ShadowCollisionType.Cylinder
|
||||||
|
? new[]
|
||||||
|
{
|
||||||
|
ShadowShape.Cylinder(
|
||||||
|
gfxObjId, Vector3.Zero, Quaternion.Identity, scale, radius, cylHeight),
|
||||||
|
}
|
||||||
|
: Array.Empty<ShadowShape>();
|
||||||
|
(cellSet, retailRoute) = ComputeContractACellArray(
|
||||||
|
seed, worldPos, rotation, state, collisionShapes, partArray!, isStatic);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// No visual part array supplied (tests, legacy) — the single-shape
|
||||||
|
// sphere flood, byte-identical to before chunk 3.
|
||||||
var spheres = new[]
|
var spheres = new[]
|
||||||
{
|
{
|
||||||
new DatReaderWriter.Types.Sphere { Origin = worldPos, Radius = radius },
|
new DatReaderWriter.Types.Sphere { Origin = worldPos, Radius = radius },
|
||||||
};
|
};
|
||||||
var cellSet = CellTransit.BuildShadowCellSet(
|
cellSet = CellTransit.BuildShadowCellSet(
|
||||||
FloodCache, seed, spheres, spheres.Length, isStatic);
|
FloodCache, seed, spheres, spheres.Length, isStatic);
|
||||||
|
}
|
||||||
if (cellSet.Count == 0) return;
|
if (cellSet.Count == 0) return;
|
||||||
|
|
||||||
DeregisterCore(entityId, publishMutation: false);
|
DeregisterCore(entityId, publishMutation: false);
|
||||||
|
|
@ -714,20 +787,13 @@ public sealed class ShadowObjectRegistry
|
||||||
else
|
else
|
||||||
RefreshOwnerPrefixIndex(entityId);
|
RefreshOwnerPrefixIndex(entityId);
|
||||||
|
|
||||||
// Campaign OVERHAUL S2 chunk 1: an untouched side product when no
|
// Campaign OVERHAUL S2 chunk 1/3: an untouched side product when no
|
||||||
// caller supplies a part array (every call site before chunk 1b).
|
// caller supplies a part array (every call site before chunk 1b).
|
||||||
if (partArray is not null)
|
// Publishes the SAME cell array computed above — no second flood.
|
||||||
|
if (hasRetailPartArray)
|
||||||
{
|
{
|
||||||
IReadOnlyList<ShadowShape> collisionShapes =
|
_entityRetailPartArrays[entityId] = partArray!;
|
||||||
collisionType == ShadowCollisionType.Cylinder
|
PublishRetailCellArray(entityId, cellSet, retailRoute, partArray!);
|
||||||
? new[]
|
|
||||||
{
|
|
||||||
ShadowShape.Cylinder(
|
|
||||||
gfxObjId, Vector3.Zero, Quaternion.Identity, scale, radius, cylHeight),
|
|
||||||
}
|
|
||||||
: Array.Empty<ShadowShape>();
|
|
||||||
RecomputeRetailCellArray(
|
|
||||||
entityId, seed, worldPos, rotation, state, collisionShapes, partArray, isStatic);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -787,11 +853,34 @@ public sealed class ShadowObjectRegistry
|
||||||
: DeriveOutdoorSeed(entityWorldPos, worldOffsetX, worldOffsetY, landblockId);
|
: DeriveOutdoorSeed(entityWorldPos, worldOffsetX, worldOffsetY, landblockId);
|
||||||
if (seed == 0u) return;
|
if (seed == 0u) return;
|
||||||
|
|
||||||
// Retail's exclusive dispatch, mirrored: CPartArray::CacheHasPhysicsBSP
|
bool hasRetailPartArray = partArray is not null && partArray.Count != 0;
|
||||||
// (0x00518110) ORs 0x10000 on the first part whose gfxobj carries a
|
|
||||||
// physics BSP, and calc_cross_cells (0x00515285) branches on that bit.
|
IReadOnlyList<uint> cellSet;
|
||||||
// AP-152 made shape emission BSP-exclusive, so "has a BSP shape" and
|
RetailCellArrayRoute retailRoute = RetailCellArrayRoute.None;
|
||||||
// "is a BSP object" coincide exactly as the cached retail flag does.
|
if (hasRetailPartArray)
|
||||||
|
{
|
||||||
|
// Campaign OVERHAUL S2 chunk 3: ONE flood, computed via Contract A
|
||||||
|
// (calc_cross_cells_static 0x00515160) over the WHOLE visual part
|
||||||
|
// array, drives BOTH the collision CELLARRAY and the retail
|
||||||
|
// render product — replacing the collision-only hasBsp dispatch
|
||||||
|
// below AND the second flood RecomputeRetailCellArray used to run
|
||||||
|
// independently. The route TEST still reads collision shapes
|
||||||
|
// (`shapes`) only (Contract A: "route from the collision shapes'
|
||||||
|
// cylspheres"), but the bbox-route flood itself always uses the
|
||||||
|
// wider `partArray`, so a decorative non-BSP part now widens
|
||||||
|
// collision membership too (Contract B / item E).
|
||||||
|
(cellSet, retailRoute) = ComputeContractACellArray(
|
||||||
|
seed, entityWorldPos, entityWorldRot, state, shapes, partArray!, isStatic);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// No visual part array supplied (tests, legacy) — retail's
|
||||||
|
// exclusive dispatch mirrored exactly as before chunk 3, BYTE-
|
||||||
|
// IDENTICAL: CPartArray::CacheHasPhysicsBSP (0x00518110) ORs
|
||||||
|
// 0x10000 on the first part whose gfxobj carries a physics BSP,
|
||||||
|
// and calc_cross_cells (0x00515285) branches on that bit. AP-152
|
||||||
|
// made shape emission BSP-exclusive, so "has a BSP shape" and "is
|
||||||
|
// a BSP object" coincide exactly as the cached retail flag does.
|
||||||
bool hasBsp = false;
|
bool hasBsp = false;
|
||||||
for (int i = 0; i < shapes.Count; i++)
|
for (int i = 0; i < shapes.Count; i++)
|
||||||
{
|
{
|
||||||
|
|
@ -802,7 +891,6 @@ public sealed class ShadowObjectRegistry
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
IReadOnlyList<uint> cellSet;
|
|
||||||
if (hasBsp)
|
if (hasBsp)
|
||||||
{
|
{
|
||||||
var partBoxes = BuildFloodPartBoxes(entityWorldPos, entityWorldRot, shapes);
|
var partBoxes = BuildFloodPartBoxes(entityWorldPos, entityWorldRot, shapes);
|
||||||
|
|
@ -816,6 +904,7 @@ public sealed class ShadowObjectRegistry
|
||||||
cellSet = CellTransit.BuildShadowCellSet(
|
cellSet = CellTransit.BuildShadowCellSet(
|
||||||
FloodCache, seed, floodSpheres, floodSpheres.Count, isStatic);
|
FloodCache, seed, floodSpheres, floodSpheres.Count, isStatic);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if (cellSet.Count == 0) return;
|
if (cellSet.Count == 0) return;
|
||||||
|
|
||||||
DeregisterCore(entityId, publishMutation: false);
|
DeregisterCore(entityId, publishMutation: false);
|
||||||
|
|
@ -859,15 +948,16 @@ public sealed class ShadowObjectRegistry
|
||||||
else
|
else
|
||||||
RefreshOwnerPrefixIndex(entityId);
|
RefreshOwnerPrefixIndex(entityId);
|
||||||
|
|
||||||
// Campaign OVERHAUL S2 chunk 1: an untouched side product when no
|
// Campaign OVERHAUL S2 chunk 1/3: an untouched side product when no
|
||||||
// caller supplies a part array (every call site before chunk 1b).
|
// caller supplies a part array (every call site before chunk 1b).
|
||||||
|
// Publishes the SAME cell array computed above — no second flood.
|
||||||
// Deliberately independent of `shapes`/hasBsp above: retail's
|
// Deliberately independent of `shapes`/hasBsp above: retail's
|
||||||
// per-part render membership (AddPartsShadow) walks EVERY visual
|
// per-part render membership (AddPartsShadow) walks EVERY visual
|
||||||
// part, not just the BSP-only collision dispatch AP-152 emits.
|
// part, not just the BSP-only collision dispatch AP-152 emits.
|
||||||
if (partArray is not null)
|
if (hasRetailPartArray)
|
||||||
{
|
{
|
||||||
RecomputeRetailCellArray(
|
_entityRetailPartArrays[entityId] = partArray!;
|
||||||
entityId, seed, entityWorldPos, entityWorldRot, state, shapes, partArray, isStatic);
|
PublishRetailCellArray(entityId, cellSet, retailRoute, partArray!);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1313,12 +1403,21 @@ public sealed class ShadowObjectRegistry
|
||||||
PreparedShadowCellReplacement[] CellReplacements,
|
PreparedShadowCellReplacement[] CellReplacements,
|
||||||
PreparedShadowPrefixReplacement[] PrefixReplacements,
|
PreparedShadowPrefixReplacement[] PrefixReplacements,
|
||||||
HashSet<uint>? OwnerPrefixes,
|
HashSet<uint>? OwnerPrefixes,
|
||||||
uint[] ChangedPrefixes);
|
uint[] ChangedPrefixes,
|
||||||
|
// Campaign OVERHAUL S2 chunk 3: the retail render product's diffed
|
||||||
|
// cell rows, parallel to CellReplacements but keyed on the entity's
|
||||||
|
// RETAIL cell array — see PrepareRetailPartEntryReplacements.
|
||||||
|
PreparedShadowRetailCellReplacement[] RetailCellReplacements);
|
||||||
|
|
||||||
internal sealed record PreparedShadowCellReplacement(
|
internal sealed record PreparedShadowCellReplacement(
|
||||||
uint CellId,
|
uint CellId,
|
||||||
List<ShadowEntry> Entries);
|
List<ShadowEntry> Entries);
|
||||||
|
|
||||||
|
/// <summary>Retail part-entry analog of <see cref="PreparedShadowCellReplacement"/>.</summary>
|
||||||
|
internal sealed record PreparedShadowRetailCellReplacement(
|
||||||
|
uint CellId,
|
||||||
|
List<RetailPartEntry> Entries);
|
||||||
|
|
||||||
internal sealed record PreparedShadowPrefixReplacement(
|
internal sealed record PreparedShadowPrefixReplacement(
|
||||||
uint Prefix,
|
uint Prefix,
|
||||||
bool Remove,
|
bool Remove,
|
||||||
|
|
@ -1379,7 +1478,8 @@ public sealed class ShadowObjectRegistry
|
||||||
CellReplacements: [],
|
CellReplacements: [],
|
||||||
PrefixReplacements: [],
|
PrefixReplacements: [],
|
||||||
OwnerPrefixes: null,
|
OwnerPrefixes: null,
|
||||||
ChangedPrefixes: Array.Empty<uint>());
|
ChangedPrefixes: Array.Empty<uint>(),
|
||||||
|
RetailCellReplacements: []);
|
||||||
return _mutationRevision == expectedMutation
|
return _mutationRevision == expectedMutation
|
||||||
&& GetOwnerVersion(entityId) == expectedOwner
|
&& GetOwnerVersion(entityId) == expectedOwner
|
||||||
&& !HasLogicalOwner(entityId);
|
&& !HasLogicalOwner(entityId);
|
||||||
|
|
@ -1414,6 +1514,8 @@ public sealed class ShadowObjectRegistry
|
||||||
uint[] changedPrefixes = CaptureChangedPrefixes(source, replacement);
|
uint[] changedPrefixes = CaptureChangedPrefixes(source, replacement);
|
||||||
PreparedShadowCellReplacement[] cellReplacements =
|
PreparedShadowCellReplacement[] cellReplacements =
|
||||||
PrepareCellReplacements(entityId, source, replacement);
|
PrepareCellReplacements(entityId, source, replacement);
|
||||||
|
PreparedShadowRetailCellReplacement[] retailCellReplacements =
|
||||||
|
PrepareRetailPartEntryReplacements(entityId, source, replacement);
|
||||||
HashSet<uint> replacementPrefixes = CapturePrefixes(replacement);
|
HashSet<uint> replacementPrefixes = CapturePrefixes(replacement);
|
||||||
PreparedShadowPrefixReplacement[] prefixReplacements =
|
PreparedShadowPrefixReplacement[] prefixReplacements =
|
||||||
PreparePrefixReplacements(
|
PreparePrefixReplacements(
|
||||||
|
|
@ -1443,6 +1545,11 @@ public sealed class ShadowObjectRegistry
|
||||||
_suspendedEntities.EnsureCapacity(_suspendedEntities.Count + 1);
|
_suspendedEntities.EnsureCapacity(_suspendedEntities.Count + 1);
|
||||||
_pendingSetPositionDispatches.EnsureCapacity(
|
_pendingSetPositionDispatches.EnsureCapacity(
|
||||||
_pendingSetPositionDispatches.Count + 1);
|
_pendingSetPositionDispatches.Count + 1);
|
||||||
|
_entityRetailPartArrays.EnsureCapacity(_entityRetailPartArrays.Count + 1);
|
||||||
|
_retailCellArrays.EnsureCapacity(_retailCellArrays.Count + 1);
|
||||||
|
_retailCellArrayRoutes.EnsureCapacity(_retailCellArrayRoutes.Count + 1);
|
||||||
|
_retailPartEntriesByCell.EnsureCapacity(
|
||||||
|
_retailPartEntriesByCell.Count + retailCellReplacements.Length);
|
||||||
|
|
||||||
prepared = new PreparedSetPositionShadowCommit(
|
prepared = new PreparedSetPositionShadowCommit(
|
||||||
checked(++_nextPreparedSetPositionCommitId),
|
checked(++_nextPreparedSetPositionCommitId),
|
||||||
|
|
@ -1456,7 +1563,8 @@ public sealed class ShadowObjectRegistry
|
||||||
cellReplacements,
|
cellReplacements,
|
||||||
prefixReplacements,
|
prefixReplacements,
|
||||||
replacementPrefixes,
|
replacementPrefixes,
|
||||||
changedPrefixes);
|
changedPrefixes,
|
||||||
|
retailCellReplacements);
|
||||||
return _mutationRevision == expectedMutation
|
return _mutationRevision == expectedMutation
|
||||||
&& GetOwnerVersion(entityId) == expectedOwner
|
&& GetOwnerVersion(entityId) == expectedOwner
|
||||||
&& HasLogicalOwner(entityId);
|
&& HasLogicalOwner(entityId);
|
||||||
|
|
@ -1504,6 +1612,12 @@ public sealed class ShadowObjectRegistry
|
||||||
prepared.CellReplacements[index];
|
prepared.CellReplacements[index];
|
||||||
_cells[replacement.CellId] = replacement.Entries;
|
_cells[replacement.CellId] = replacement.Entries;
|
||||||
}
|
}
|
||||||
|
for (int index = 0; index < prepared.RetailCellReplacements.Length; index++)
|
||||||
|
{
|
||||||
|
PreparedShadowRetailCellReplacement replacement =
|
||||||
|
prepared.RetailCellReplacements[index];
|
||||||
|
_retailPartEntriesByCell[replacement.CellId] = replacement.Entries;
|
||||||
|
}
|
||||||
PreparedShadowOwnerState state = prepared.OwnerState;
|
PreparedShadowOwnerState state = prepared.OwnerState;
|
||||||
_entityReg[prepared.EntityId] = state.Registration;
|
_entityReg[prepared.EntityId] = state.Registration;
|
||||||
ReplaceOwnerValue(_entityShapes, prepared.EntityId, state.Shapes);
|
ReplaceOwnerValue(_entityShapes, prepared.EntityId, state.Shapes);
|
||||||
|
|
@ -1523,6 +1637,23 @@ public sealed class ShadowObjectRegistry
|
||||||
_entityToCells,
|
_entityToCells,
|
||||||
prepared.EntityId,
|
prepared.EntityId,
|
||||||
state.CellIds);
|
state.CellIds);
|
||||||
|
// Campaign OVERHAUL S2 chunk 3: publish the staged retail render
|
||||||
|
// product alongside collision — the same fields PublishRetailCellArray
|
||||||
|
// maintains on the direct (non-staged) path.
|
||||||
|
if (state.RetailPartArray is not null)
|
||||||
|
{
|
||||||
|
_entityRetailPartArrays[prepared.EntityId] = state.RetailPartArray;
|
||||||
|
_retailCellArrayRoutes[prepared.EntityId] = state.RetailRoute;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_entityRetailPartArrays.Remove(prepared.EntityId);
|
||||||
|
_retailCellArrayRoutes.Remove(prepared.EntityId);
|
||||||
|
}
|
||||||
|
ReplaceOwnerValue(
|
||||||
|
_retailCellArrays,
|
||||||
|
prepared.EntityId,
|
||||||
|
state.RetailCellIds);
|
||||||
if (prepared.OwnerPrefixes is not null)
|
if (prepared.OwnerPrefixes is not null)
|
||||||
_ownerPrefixes[prepared.EntityId] = prepared.OwnerPrefixes;
|
_ownerPrefixes[prepared.EntityId] = prepared.OwnerPrefixes;
|
||||||
for (int index = 0; index < prepared.PrefixReplacements.Length; index++)
|
for (int index = 0; index < prepared.PrefixReplacements.Length; index++)
|
||||||
|
|
@ -1743,6 +1874,73 @@ public sealed class ShadowObjectRegistry
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail part-entry analog of <see cref="PrepareCellReplacements"/> —
|
||||||
|
/// diffs <paramref name="before"/>/<paramref name="after"/>'s retail rows
|
||||||
|
/// against the LIVE <see cref="_retailPartEntriesByCell"/> so
|
||||||
|
/// <see cref="TryApplySetPosition"/> can publish the staged retail
|
||||||
|
/// product with the same retained-others-plus-owner's-new-rows recipe
|
||||||
|
/// the collision cells use. Keyed on retail cell ids (from
|
||||||
|
/// <see cref="PreparedShadowOwnerState.RetailCellIds"/>/<c>RetailRows</c>),
|
||||||
|
/// which need not equal <see cref="PreparedShadowOwnerState.CellIds"/> —
|
||||||
|
/// the two products are independent floods for callers with no retained
|
||||||
|
/// part array, and coincide only once chunk 3's single-flood
|
||||||
|
/// registrations are the sole populators.
|
||||||
|
/// </summary>
|
||||||
|
private PreparedShadowRetailCellReplacement[] PrepareRetailPartEntryReplacements(
|
||||||
|
uint entityId,
|
||||||
|
PreparedShadowOwnerState before,
|
||||||
|
PreparedShadowOwnerState after)
|
||||||
|
{
|
||||||
|
var touched = new HashSet<uint>();
|
||||||
|
AddCells(touched, before.RetailCellIds);
|
||||||
|
AddCells(touched, after.RetailCellIds);
|
||||||
|
var afterRows = new Dictionary<uint, RetailPartEntry[]>();
|
||||||
|
for (int index = 0; index < after.RetailRows.Count; index++)
|
||||||
|
{
|
||||||
|
PreparedShadowRetailPartRows row = after.RetailRows[index];
|
||||||
|
touched.Add(row.CellId);
|
||||||
|
afterRows[row.CellId] = row.Entries;
|
||||||
|
}
|
||||||
|
for (int index = 0; index < before.RetailRows.Count; index++)
|
||||||
|
touched.Add(before.RetailRows[index].CellId);
|
||||||
|
|
||||||
|
uint[] ordered = touched.ToArray();
|
||||||
|
Array.Sort(ordered);
|
||||||
|
var result = new PreparedShadowRetailCellReplacement[ordered.Length];
|
||||||
|
for (int index = 0; index < ordered.Length; index++)
|
||||||
|
{
|
||||||
|
uint cellId = ordered[index];
|
||||||
|
_retailPartEntriesByCell.TryGetValue(cellId, out List<RetailPartEntry>? active);
|
||||||
|
afterRows.TryGetValue(cellId, out RetailPartEntry[]? ownerRows);
|
||||||
|
int retainedCount = 0;
|
||||||
|
if (active is not null)
|
||||||
|
{
|
||||||
|
for (int row = 0; row < active.Count; row++)
|
||||||
|
{
|
||||||
|
if (active[row].EntityId != entityId)
|
||||||
|
retainedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var replacement = new List<RetailPartEntry>(
|
||||||
|
retainedCount + (ownerRows?.Length ?? 0));
|
||||||
|
if (active is not null)
|
||||||
|
{
|
||||||
|
for (int row = 0; row < active.Count; row++)
|
||||||
|
{
|
||||||
|
if (active[row].EntityId != entityId)
|
||||||
|
replacement.Add(active[row]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (ownerRows is not null)
|
||||||
|
replacement.AddRange(ownerRows);
|
||||||
|
result[index] = new PreparedShadowRetailCellReplacement(
|
||||||
|
cellId,
|
||||||
|
replacement);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
private PreparedShadowPrefixReplacement[] PreparePrefixReplacements(
|
private PreparedShadowPrefixReplacement[] PreparePrefixReplacements(
|
||||||
uint entityId,
|
uint entityId,
|
||||||
HashSet<uint> before,
|
HashSet<uint> before,
|
||||||
|
|
@ -2879,6 +3077,37 @@ public sealed class ShadowObjectRegistry
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Campaign OVERHAUL S2 chunk 3: capture the retail render product
|
||||||
|
// alongside the collision rows so the staging registry (InstallOwnerState)
|
||||||
|
// can recompute it faithfully, and so TryApplySetPosition can publish
|
||||||
|
// whatever the staged CommitSetPosition produced.
|
||||||
|
_entityRetailPartArrays.TryGetValue(
|
||||||
|
entityId,
|
||||||
|
out IReadOnlyList<ShadowShape>? retailPartArray);
|
||||||
|
_retailCellArrays.TryGetValue(entityId, out List<uint>? retailCells);
|
||||||
|
RetailCellArrayRoute retailRoute = _retailCellArrayRoutes.TryGetValue(
|
||||||
|
entityId,
|
||||||
|
out RetailCellArrayRoute capturedRoute)
|
||||||
|
? capturedRoute
|
||||||
|
: RetailCellArrayRoute.None;
|
||||||
|
var retailRows = new List<PreparedShadowRetailPartRows>();
|
||||||
|
if (retailCells is not null)
|
||||||
|
{
|
||||||
|
foreach (uint cellId in retailCells)
|
||||||
|
{
|
||||||
|
if (_retailPartEntriesByCell.TryGetValue(
|
||||||
|
cellId,
|
||||||
|
out List<RetailPartEntry>? entries))
|
||||||
|
{
|
||||||
|
retailRows.Add(new PreparedShadowRetailPartRows(
|
||||||
|
cellId,
|
||||||
|
entries.Where(entry => entry.EntityId == entityId)
|
||||||
|
.ToArray()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
state = new PreparedShadowOwnerState(
|
state = new PreparedShadowOwnerState(
|
||||||
entityId,
|
entityId,
|
||||||
registration,
|
registration,
|
||||||
|
|
@ -2887,7 +3116,11 @@ public sealed class ShadowObjectRegistry
|
||||||
rows,
|
rows,
|
||||||
_suspendedEntities.Contains(entityId),
|
_suspendedEntities.Contains(entityId),
|
||||||
suspendedCells is null ? null : new List<uint>(suspendedCells),
|
suspendedCells is null ? null : new List<uint>(suspendedCells),
|
||||||
withdrawn is null ? null : new HashSet<uint>(withdrawn));
|
withdrawn is null ? null : new HashSet<uint>(withdrawn),
|
||||||
|
retailPartArray,
|
||||||
|
retailCells is null ? null : new List<uint>(retailCells),
|
||||||
|
retailRoute,
|
||||||
|
retailRows);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2912,6 +3145,32 @@ public sealed class ShadowObjectRegistry
|
||||||
for (int entryIndex = 0; entryIndex < row.Entries.Length; entryIndex++)
|
for (int entryIndex = 0; entryIndex < row.Entries.Length; entryIndex++)
|
||||||
AddEntryToCell(row.Entries[entryIndex], row.CellId);
|
AddEntryToCell(row.Entries[entryIndex], row.CellId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Campaign OVERHAUL S2 chunk 3: seed the retail render product too —
|
||||||
|
// without this, a staging registry's own internal Contract-A
|
||||||
|
// recompute (RecomputeRetailCellArrayIfPresent, reached through
|
||||||
|
// CommitSetPosition) finds nothing retained and silently no-ops,
|
||||||
|
// dropping the retail product for every entity that moves through
|
||||||
|
// the staged TryPrepareSetPosition/TryApplySetPosition pipeline.
|
||||||
|
if (state.RetailPartArray is not null)
|
||||||
|
{
|
||||||
|
_entityRetailPartArrays[state.EntityId] = state.RetailPartArray;
|
||||||
|
_retailCellArrayRoutes[state.EntityId] = state.RetailRoute;
|
||||||
|
}
|
||||||
|
if (state.RetailCellIds is not null)
|
||||||
|
_retailCellArrays[state.EntityId] = state.RetailCellIds;
|
||||||
|
for (int rowIndex = 0; rowIndex < state.RetailRows.Count; rowIndex++)
|
||||||
|
{
|
||||||
|
PreparedShadowRetailPartRows row = state.RetailRows[rowIndex];
|
||||||
|
if (!_retailPartEntriesByCell.TryGetValue(
|
||||||
|
row.CellId,
|
||||||
|
out List<RetailPartEntry>? entries))
|
||||||
|
{
|
||||||
|
entries = new List<RetailPartEntry>();
|
||||||
|
_retailPartEntriesByCell[row.CellId] = entries;
|
||||||
|
}
|
||||||
|
entries.AddRange(row.Entries);
|
||||||
|
}
|
||||||
BumpOwnerVersion(state.EntityId);
|
BumpOwnerVersion(state.EntityId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3112,12 +3371,30 @@ public sealed class ShadowObjectRegistry
|
||||||
IReadOnlyList<PreparedShadowCellRows> Rows,
|
IReadOnlyList<PreparedShadowCellRows> Rows,
|
||||||
bool Suspended,
|
bool Suspended,
|
||||||
List<uint>? SuspendedCellIds,
|
List<uint>? SuspendedCellIds,
|
||||||
HashSet<uint>? WithdrawnPrefixes);
|
HashSet<uint>? WithdrawnPrefixes,
|
||||||
|
// Campaign OVERHAUL S2 chunk 3: the retail render product
|
||||||
|
// (RecomputeRetailCellArray's retained side product) travels with
|
||||||
|
// the owner state so a live entity moving through
|
||||||
|
// TryPrepareSetPosition/TryApplySetPosition keeps both products
|
||||||
|
// consistent with the direct CommitSetPosition path — the chunk-1
|
||||||
|
// gap where the staging registry never saw these fields, so its own
|
||||||
|
// internal Contract-A recompute silently no-opped.
|
||||||
|
IReadOnlyList<ShadowShape>? RetailPartArray,
|
||||||
|
List<uint>? RetailCellIds,
|
||||||
|
RetailCellArrayRoute RetailRoute,
|
||||||
|
IReadOnlyList<PreparedShadowRetailPartRows> RetailRows);
|
||||||
|
|
||||||
internal sealed record PreparedShadowCellRows(
|
internal sealed record PreparedShadowCellRows(
|
||||||
uint CellId,
|
uint CellId,
|
||||||
ShadowEntry[] Entries);
|
ShadowEntry[] Entries);
|
||||||
|
|
||||||
|
/// <summary>Retail part-entry analog of <see cref="PreparedShadowCellRows"/>
|
||||||
|
/// for one cell of <see cref="ShadowObjectRegistry.GetRetailPartEntriesInCell"/>'s
|
||||||
|
/// backing store.</summary>
|
||||||
|
internal sealed record PreparedShadowRetailPartRows(
|
||||||
|
uint CellId,
|
||||||
|
RetailPartEntry[] Entries);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Retires the complete logical registry at terminal physics-engine
|
/// Retires the complete logical registry at terminal physics-engine
|
||||||
/// disposal, including suspended live registrations that own no cell row.
|
/// disposal, including suspended live registrations that own no cell row.
|
||||||
|
|
|
||||||
|
|
@ -360,4 +360,208 @@ public class ShadowObjectRegistryRetailCellArrayTests
|
||||||
Assert.Equal(0x7003u, entries[1].GfxObjId);
|
Assert.Equal(0x7003u, entries[1].GfxObjId);
|
||||||
Assert.DoesNotContain(entries, e => e.GfxObjId == 0x7001u);
|
Assert.DoesNotContain(entries, e => e.GfxObjId == 0x7001u);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Campaign OVERHAUL S2 chunk 3: one flood per registration. Both
|
||||||
|
// _entityToCells/_cells (collision) and the retail render product now
|
||||||
|
// come from the SAME CELLARRAY when a caller supplies partArray — a
|
||||||
|
// decorative non-BSP-colliding visual part widens COLLISION membership
|
||||||
|
// too, not just the retail product. The BSP test still decides contact
|
||||||
|
// at query time; only membership (which cells hold a row at all) widens.
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RegisterMultiPart_DecorativePartArrayEntry_WidensCollisionRowsToo()
|
||||||
|
{
|
||||||
|
var reg = new ShadowObjectRegistry();
|
||||||
|
const uint entityId = 0x20u;
|
||||||
|
const uint collidingGfxObj = 0xAAAA0001u;
|
||||||
|
const uint decorativeGfxObj = 0xBBBB0001u;
|
||||||
|
|
||||||
|
// The entity's actual COLLISION geometry is a single BSP part at the
|
||||||
|
// entity's own origin (CellA only, on its own — AP-152's exclusive
|
||||||
|
// collision dispatch).
|
||||||
|
IReadOnlyList<ShadowShape> collisionShapes = new[] { Bsp(collidingGfxObj) };
|
||||||
|
// Its WHOLE VISUAL part array (chunk 1b's partArray input) additionally
|
||||||
|
// carries a decorative part 30 m away with no collision of its own.
|
||||||
|
// ShadowShapeBuilder.FromStaticRenderParts/FromSetupRenderParts tag
|
||||||
|
// EVERY visual part CollisionType.BSP as a pure geometry carrier
|
||||||
|
// regardless of whether its GfxObj has a real physics BSP, so this
|
||||||
|
// mirrors production exactly.
|
||||||
|
IReadOnlyList<ShadowShape> partArray = new[]
|
||||||
|
{
|
||||||
|
Bsp(collidingGfxObj),
|
||||||
|
Bsp(decorativeGfxObj, new Vector3(30f, 0f, 0f)),
|
||||||
|
};
|
||||||
|
|
||||||
|
reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, collisionShapes,
|
||||||
|
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
|
||||||
|
partArray: partArray);
|
||||||
|
|
||||||
|
// Retail CELLARRAY reaches both cells — the decorative part's box
|
||||||
|
// pulls in CellB even though it cannot collide.
|
||||||
|
Assert.True(reg.TryGetRetailCellArray(entityId, out var retailCells));
|
||||||
|
Assert.Equal(new[] { CellA, CellB }, retailCells);
|
||||||
|
Assert.Equal(RetailCellArrayRoute.BoundingBox, reg.GetRetailCellArrayRoute(entityId));
|
||||||
|
|
||||||
|
// Chunk 3: the SAME array now drives collision. Retail == collision
|
||||||
|
// by construction, not coincidence.
|
||||||
|
Assert.Equal(retailCells, reg.GetOwnerCells(entityId));
|
||||||
|
|
||||||
|
foreach (uint cellId in new[] { CellA, CellB })
|
||||||
|
{
|
||||||
|
var collisionRows = reg.GetObjectsInCell(cellId)
|
||||||
|
.Where(e => e.EntityId == entityId)
|
||||||
|
.ToList();
|
||||||
|
Assert.Single(collisionRows);
|
||||||
|
Assert.Equal(collidingGfxObj, collisionRows[0].GfxObjId);
|
||||||
|
|
||||||
|
// The decorative part never appears as a COLLISION row (AP-152:
|
||||||
|
// the collision dispatch stays BSP-exclusive over collisionShapes)
|
||||||
|
// — only as a retail render part entry.
|
||||||
|
Assert.DoesNotContain(collisionRows, e => e.GfxObjId == decorativeGfxObj);
|
||||||
|
|
||||||
|
var partEntries = reg.GetRetailPartEntriesInCell(cellId)
|
||||||
|
.Where(e => e.EntityId == entityId)
|
||||||
|
.Select(e => e.GfxObjId)
|
||||||
|
.ToList();
|
||||||
|
Assert.Contains(collidingGfxObj, partEntries);
|
||||||
|
Assert.Contains(decorativeGfxObj, partEntries);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RegisterMultiPart_WithoutDecorativeParts_RetailArrayStillEqualsCollisionCells()
|
||||||
|
{
|
||||||
|
// BSP-only shapes with partArray == shapes: retail array and
|
||||||
|
// collision cells must agree exactly, whether or not a partArray is
|
||||||
|
// supplied at all (item F: "registering with partArray and without
|
||||||
|
// must produce identical _entityToCells for BSP-only shapes").
|
||||||
|
var withPartArray = new ShadowObjectRegistry();
|
||||||
|
var withoutPartArray = new ShadowObjectRegistry();
|
||||||
|
const uint entityId = 0x21u;
|
||||||
|
IReadOnlyList<ShadowShape> shapes = new[] { Bsp(0x0100_0055u, radius: 2f) };
|
||||||
|
|
||||||
|
withPartArray.RegisterMultiPart(entityId, Pos, Quaternion.Identity, shapes,
|
||||||
|
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
|
||||||
|
seedCellId: CellA, isStatic: true, partArray: shapes);
|
||||||
|
withoutPartArray.RegisterMultiPart(entityId, Pos, Quaternion.Identity, shapes,
|
||||||
|
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
|
||||||
|
seedCellId: CellA, isStatic: true);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
withoutPartArray.GetOwnerCells(entityId),
|
||||||
|
withPartArray.GetOwnerCells(entityId));
|
||||||
|
Assert.True(withPartArray.TryGetRetailCellArray(entityId, out var retailCells));
|
||||||
|
Assert.Equal(withPartArray.GetOwnerCells(entityId), retailCells);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Campaign OVERHAUL S2 chunk 3 item B: the staged
|
||||||
|
// TryPrepareSetPosition/TryApplySetPosition pipeline must carry the
|
||||||
|
// retail render product exactly like the direct CommitSetPosition path
|
||||||
|
// — the chunk-1 gap where InstallOwnerState never seeded the staging
|
||||||
|
// registry's retail dictionaries, so its own internal Contract-A
|
||||||
|
// recompute silently no-opped and the applied commit dropped the retail
|
||||||
|
// product on the floor.
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StagedSetPosition_WithRetainedPartArray_MovesBothProductsTogether()
|
||||||
|
{
|
||||||
|
var reg = new ShadowObjectRegistry();
|
||||||
|
const uint entityId = 0x30u;
|
||||||
|
IReadOnlyList<ShadowShape> parts = new[] { Cyl(0x8001u) };
|
||||||
|
|
||||||
|
reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, parts,
|
||||||
|
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId, partArray: parts);
|
||||||
|
Assert.True(reg.TryGetRetailCellArray(entityId, out var before));
|
||||||
|
Assert.Equal(new[] { CellA }, before);
|
||||||
|
Assert.Equal(new[] { CellA }, reg.GetOwnerCells(entityId));
|
||||||
|
|
||||||
|
var moved = new Vector3(42f, 12f, 50f); // 30 m away in X -> CellB
|
||||||
|
Assert.True(reg.TryPrepareSetPosition(
|
||||||
|
entityId,
|
||||||
|
moved,
|
||||||
|
Quaternion.Identity,
|
||||||
|
seedCellId: CellB,
|
||||||
|
worldOffsetX: OffX,
|
||||||
|
worldOffsetY: OffY,
|
||||||
|
PhysicsShadowCommitAction.Replace,
|
||||||
|
crossCellIds: [CellB],
|
||||||
|
provenShapeless: false,
|
||||||
|
suspendOwner: false,
|
||||||
|
out var prepared));
|
||||||
|
|
||||||
|
// Not yet applied — both products still describe the OLD position.
|
||||||
|
Assert.Equal(new[] { CellA }, reg.GetOwnerCells(entityId));
|
||||||
|
Assert.True(reg.TryGetRetailCellArray(entityId, out var stillBefore));
|
||||||
|
Assert.Equal(new[] { CellA }, stillBefore);
|
||||||
|
|
||||||
|
Assert.True(reg.TryApplySetPosition(prepared!, out var receipt));
|
||||||
|
Assert.True(receipt.Mutated);
|
||||||
|
|
||||||
|
Assert.Equal(new[] { CellB }, reg.GetOwnerCells(entityId));
|
||||||
|
Assert.True(reg.TryGetRetailCellArray(entityId, out var after));
|
||||||
|
Assert.Equal(new[] { CellB }, after);
|
||||||
|
Assert.Equal(RetailCellArrayRoute.Cylsphere, reg.GetRetailCellArrayRoute(entityId));
|
||||||
|
|
||||||
|
var partEntries = reg.GetRetailPartEntriesInCell(CellB)
|
||||||
|
.Where(e => e.EntityId == entityId).ToList();
|
||||||
|
Assert.Single(partEntries);
|
||||||
|
Assert.Equal(0x8001u, partEntries[0].GfxObjId);
|
||||||
|
Assert.DoesNotContain(
|
||||||
|
reg.GetRetailPartEntriesInCell(CellA), e => e.EntityId == entityId);
|
||||||
|
|
||||||
|
var collisionRows = reg.GetObjectsInCell(CellB)
|
||||||
|
.Where(e => e.EntityId == entityId).ToList();
|
||||||
|
Assert.Single(collisionRows);
|
||||||
|
Assert.Equal(0x8001u, collisionRows[0].GfxObjId);
|
||||||
|
Assert.DoesNotContain(
|
||||||
|
reg.GetObjectsInCell(CellA), e => e.EntityId == entityId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StagedSetPosition_KeepWhenEmpty_PreservesBothProductsTogether()
|
||||||
|
{
|
||||||
|
var reg = new ShadowObjectRegistry();
|
||||||
|
const uint entityId = 0x31u;
|
||||||
|
IReadOnlyList<ShadowShape> parts = new[] { Cyl(0x8101u) };
|
||||||
|
|
||||||
|
reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, parts,
|
||||||
|
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId, partArray: parts);
|
||||||
|
Assert.True(reg.TryGetRetailCellArray(entityId, out var before));
|
||||||
|
Assert.Equal(new[] { CellA }, before);
|
||||||
|
Assert.Equal(new[] { CellA }, reg.GetOwnerCells(entityId));
|
||||||
|
|
||||||
|
// seedCellId: 0 with the Recalculate action resolves NO seed at all
|
||||||
|
// (landblockId derives from seedCellId, so it is also 0 —
|
||||||
|
// DeriveOutdoorSeed's own guard) — retail keeps the previous
|
||||||
|
// shadows (SetPositionInternal num_cells gate, pc:283540). The
|
||||||
|
// staged pipeline must honor this for BOTH products, not just
|
||||||
|
// collision.
|
||||||
|
Assert.True(reg.TryPrepareSetPosition(
|
||||||
|
entityId,
|
||||||
|
new Vector3(999f, 999f, 50f),
|
||||||
|
Quaternion.Identity,
|
||||||
|
seedCellId: 0u,
|
||||||
|
worldOffsetX: OffX,
|
||||||
|
worldOffsetY: OffY,
|
||||||
|
PhysicsShadowCommitAction.Recalculate,
|
||||||
|
crossCellIds: ImmutableArray<uint>.Empty,
|
||||||
|
provenShapeless: false,
|
||||||
|
suspendOwner: false,
|
||||||
|
out var prepared));
|
||||||
|
Assert.True(reg.TryApplySetPosition(prepared!, out _));
|
||||||
|
|
||||||
|
Assert.Equal(new[] { CellA }, reg.GetOwnerCells(entityId));
|
||||||
|
Assert.True(reg.TryGetRetailCellArray(entityId, out var after));
|
||||||
|
Assert.Equal(new[] { CellA }, after);
|
||||||
|
Assert.Equal(RetailCellArrayRoute.Cylsphere, reg.GetRetailCellArrayRoute(entityId));
|
||||||
|
|
||||||
|
var partEntries = reg.GetRetailPartEntriesInCell(CellA)
|
||||||
|
.Where(e => e.EntityId == entityId).ToList();
|
||||||
|
Assert.Single(partEntries);
|
||||||
|
Assert.Equal(0x8101u, partEntries[0].GfxObjId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue