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:
Erik 2026-09-02 23:02:47 +02:00
parent d79058cec4
commit afbd241016
4 changed files with 665 additions and 85 deletions

View file

@ -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.
**Retail static render-shadow cell membership (2026-08-31; ownership moved
to `ShadowObjectRegistry` by Campaign OVERHAUL S2 chunk 2).** Indoor statics
are not rendered solely from their authored parent EnvCell. Retail's
`CEnvCell::init_static_objects` creates a `CPhysicsObj` for every static,
then `calc_cross_cells_static` + `CPartArray::AddPartsShadow` places every
visual part in each crossed cell's `shadow_part_list`, including decorative
GfxObjs that have no collision BSP. `ShadowObjectRegistry` owns this product
as a retained per-entity retail CELLARRAY (`TryGetRetailCellArray`) plus
per-cell part entries (`GetRetailPartEntriesInCell`), computed once at the
SAME registration transaction as the collision flood
(`RegisterMultiPart`/`Register`'s optional `partArray:` parameter) — not
rebuilt per frame. `WalkProductionWorldData`'s indoor and outdoor static
sweeps BORROW that array whole per record; they no longer recompute
membership from each MeshRef's prepared visual AABB (the deleted
`ResolveStaticRenderCells` / `ShadowObjectRegistry.ComputeStaticRenderCells`
pair). The retail CELLARRAY remains distinct from `ShadowObjectRegistry`'s
own collision rows (`GetOwnerCells`/`_entityToCells`) — the two retail lists
share transit math but have different membership (collision cutover to the
same CELLARRAY is S2 chunk 3) — until then, 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`.
to `ShadowObjectRegistry` by Campaign OVERHAUL S2 chunk 2; collision cutover
by S2 chunk 3, 2026-09-02).** Indoor statics are not rendered solely from
their authored parent EnvCell. Retail's `CEnvCell::init_static_objects`
creates a `CPhysicsObj` for every static, then `calc_cross_cells_static` +
`CPartArray::AddPartsShadow` places every visual part in each crossed cell's
`shadow_part_list`, including decorative GfxObjs that have no collision BSP —
one CELLARRAY flood feeds BOTH the collision `shadow_object_list` and the
render `shadow_part_list` (Contract B). `ShadowObjectRegistry` now mirrors
this exactly: `RegisterMultiPart`/`Register`'s optional `partArray:`
parameter, when supplied (every production call site since chunk 1b), drives
ONE Contract A flood (`ComputeContractACellArray`) whose result becomes BOTH
`GetOwnerCells`/`_entityToCells`/`_cells` (collision) and the retained
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
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
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

View file

@ -36,9 +36,10 @@ One transaction per object (`CPhysicsObj::calc_cross_cells_static`
| 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 |
| **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` |
| `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).** `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` |
| **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` |
| `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) |
@ -49,7 +50,8 @@ Findings that shape the chunks:
- 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
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
cell (identity retained per part, with cell frame and clip planes).
- 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
Y=192 edge, producing 2 of 6 retail cells in the neighboring landblock
`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.

View file

@ -450,7 +450,36 @@ public sealed class ShadowObjectRegistry
return;
_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,
// which acdream carries as the Cylinder shapes of the COLLISION
// dispatch — never as visual parts.
@ -465,30 +494,47 @@ public sealed class ShadowObjectRegistry
}
bool cylsphereRoute = (state & 0x10000u) == 0u && hasCylsphere;
IReadOnlyList<uint> cellArray;
RetailCellArrayRoute route;
if (cylsphereRoute)
{
route = RetailCellArrayRoute.Cylsphere;
List<DatReaderWriter.Types.Sphere> cylSpheres =
BuildFloodSpheres(entityWorldPos, entityWorldRot, collisionShapes);
cellArray = CellTransit.BuildShadowCellSet(
BuildFloodSpheres(worldPos, worldRot, collisionShapes);
IReadOnlyList<uint> cells = CellTransit.BuildShadowCellSet(
FloodCache, seedCellId, cylSpheres, cylSpheres.Count, isStatic);
return (cells, RetailCellArrayRoute.Cylsphere);
}
else
{
route = RetailCellArrayRoute.BoundingBox;
List<ShadowPartBox> boxes =
BuildFloodPartBoxes(entityWorldPos, entityWorldRot, partArray);
BuildFloodPartBoxes(worldPos, worldRot, partArray);
List<DatReaderWriter.Types.Sphere> spheres =
BuildBspPartSpheres(entityWorldPos, entityWorldRot, partArray);
cellArray = CellTransit.BuildShadowCellSetFromParts(
BuildBspPartSpheres(worldPos, worldRot, partArray);
IReadOnlyList<uint> cells = CellTransit.BuildShadowCellSetFromParts(
FloodCache, seedCellId, boxes, spheres, isStatic);
return (cells, RetailCellArrayRoute.BoundingBox);
}
}
// The route is recorded even when the flood came back empty — the
// dispatch decision was still made (mirrors GetRetailCellArrayRoute
// being independently observable from cell-list non-emptiness).
/// <summary>
/// Publishes the retail render product for <paramref name="entityId"/>
/// 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;
if (cellArray.Count == 0)
return;
@ -685,12 +731,39 @@ public sealed class ShadowObjectRegistry
: DeriveOutdoorSeed(worldPos, worldOffsetX, worldOffsetY, landblockId);
if (seed == 0u) return;
var spheres = new[]
bool hasRetailPartArray = partArray is not null && partArray.Count != 0;
IReadOnlyList<uint> cellSet;
RetailCellArrayRoute retailRoute = RetailCellArrayRoute.None;
if (hasRetailPartArray)
{
new DatReaderWriter.Types.Sphere { Origin = worldPos, Radius = radius },
};
var cellSet = CellTransit.BuildShadowCellSet(
FloodCache, seed, spheres, spheres.Length, isStatic);
// 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[]
{
new DatReaderWriter.Types.Sphere { Origin = worldPos, Radius = radius },
};
cellSet = CellTransit.BuildShadowCellSet(
FloodCache, seed, spheres, spheres.Length, isStatic);
}
if (cellSet.Count == 0) return;
DeregisterCore(entityId, publishMutation: false);
@ -714,20 +787,13 @@ public sealed class ShadowObjectRegistry
else
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).
if (partArray is not null)
// Publishes the SAME cell array computed above — no second flood.
if (hasRetailPartArray)
{
IReadOnlyList<ShadowShape> collisionShapes =
collisionType == ShadowCollisionType.Cylinder
? new[]
{
ShadowShape.Cylinder(
gfxObjId, Vector3.Zero, Quaternion.Identity, scale, radius, cylHeight),
}
: Array.Empty<ShadowShape>();
RecomputeRetailCellArray(
entityId, seed, worldPos, rotation, state, collisionShapes, partArray, isStatic);
_entityRetailPartArrays[entityId] = partArray!;
PublishRetailCellArray(entityId, cellSet, retailRoute, partArray!);
}
}
@ -787,34 +853,57 @@ public sealed class ShadowObjectRegistry
: DeriveOutdoorSeed(entityWorldPos, worldOffsetX, worldOffsetY, landblockId);
if (seed == 0u) return;
// Retail's exclusive dispatch, mirrored: 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;
for (int i = 0; i < shapes.Count; i++)
{
if (shapes[i].CollisionType == ShadowCollisionType.BSP)
{
hasBsp = true;
break;
}
}
bool hasRetailPartArray = partArray is not null && partArray.Count != 0;
IReadOnlyList<uint> cellSet;
if (hasBsp)
RetailCellArrayRoute retailRoute = RetailCellArrayRoute.None;
if (hasRetailPartArray)
{
var partBoxes = BuildFloodPartBoxes(entityWorldPos, entityWorldRot, shapes);
var partSpheres = BuildBspPartSpheres(entityWorldPos, entityWorldRot, shapes);
cellSet = CellTransit.BuildShadowCellSetFromParts(
FloodCache, seed, partBoxes, partSpheres, isStatic);
// 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
{
var floodSpheres = BuildFloodSpheres(entityWorldPos, entityWorldRot, shapes);
cellSet = CellTransit.BuildShadowCellSet(
FloodCache, seed, floodSpheres, floodSpheres.Count, isStatic);
// 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;
for (int i = 0; i < shapes.Count; i++)
{
if (shapes[i].CollisionType == ShadowCollisionType.BSP)
{
hasBsp = true;
break;
}
}
if (hasBsp)
{
var partBoxes = BuildFloodPartBoxes(entityWorldPos, entityWorldRot, shapes);
var partSpheres = BuildBspPartSpheres(entityWorldPos, entityWorldRot, shapes);
cellSet = CellTransit.BuildShadowCellSetFromParts(
FloodCache, seed, partBoxes, partSpheres, isStatic);
}
else
{
var floodSpheres = BuildFloodSpheres(entityWorldPos, entityWorldRot, shapes);
cellSet = CellTransit.BuildShadowCellSet(
FloodCache, seed, floodSpheres, floodSpheres.Count, isStatic);
}
}
if (cellSet.Count == 0) return;
@ -859,15 +948,16 @@ public sealed class ShadowObjectRegistry
else
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).
// Publishes the SAME cell array computed above — no second flood.
// Deliberately independent of `shapes`/hasBsp above: retail's
// per-part render membership (AddPartsShadow) walks EVERY visual
// part, not just the BSP-only collision dispatch AP-152 emits.
if (partArray is not null)
if (hasRetailPartArray)
{
RecomputeRetailCellArray(
entityId, seed, entityWorldPos, entityWorldRot, state, shapes, partArray, isStatic);
_entityRetailPartArrays[entityId] = partArray!;
PublishRetailCellArray(entityId, cellSet, retailRoute, partArray!);
}
}
@ -1313,12 +1403,21 @@ public sealed class ShadowObjectRegistry
PreparedShadowCellReplacement[] CellReplacements,
PreparedShadowPrefixReplacement[] PrefixReplacements,
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(
uint CellId,
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(
uint Prefix,
bool Remove,
@ -1379,7 +1478,8 @@ public sealed class ShadowObjectRegistry
CellReplacements: [],
PrefixReplacements: [],
OwnerPrefixes: null,
ChangedPrefixes: Array.Empty<uint>());
ChangedPrefixes: Array.Empty<uint>(),
RetailCellReplacements: []);
return _mutationRevision == expectedMutation
&& GetOwnerVersion(entityId) == expectedOwner
&& !HasLogicalOwner(entityId);
@ -1414,6 +1514,8 @@ public sealed class ShadowObjectRegistry
uint[] changedPrefixes = CaptureChangedPrefixes(source, replacement);
PreparedShadowCellReplacement[] cellReplacements =
PrepareCellReplacements(entityId, source, replacement);
PreparedShadowRetailCellReplacement[] retailCellReplacements =
PrepareRetailPartEntryReplacements(entityId, source, replacement);
HashSet<uint> replacementPrefixes = CapturePrefixes(replacement);
PreparedShadowPrefixReplacement[] prefixReplacements =
PreparePrefixReplacements(
@ -1443,6 +1545,11 @@ public sealed class ShadowObjectRegistry
_suspendedEntities.EnsureCapacity(_suspendedEntities.Count + 1);
_pendingSetPositionDispatches.EnsureCapacity(
_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(
checked(++_nextPreparedSetPositionCommitId),
@ -1456,7 +1563,8 @@ public sealed class ShadowObjectRegistry
cellReplacements,
prefixReplacements,
replacementPrefixes,
changedPrefixes);
changedPrefixes,
retailCellReplacements);
return _mutationRevision == expectedMutation
&& GetOwnerVersion(entityId) == expectedOwner
&& HasLogicalOwner(entityId);
@ -1504,6 +1612,12 @@ public sealed class ShadowObjectRegistry
prepared.CellReplacements[index];
_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;
_entityReg[prepared.EntityId] = state.Registration;
ReplaceOwnerValue(_entityShapes, prepared.EntityId, state.Shapes);
@ -1523,6 +1637,23 @@ public sealed class ShadowObjectRegistry
_entityToCells,
prepared.EntityId,
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)
_ownerPrefixes[prepared.EntityId] = prepared.OwnerPrefixes;
for (int index = 0; index < prepared.PrefixReplacements.Length; index++)
@ -1743,6 +1874,73 @@ public sealed class ShadowObjectRegistry
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(
uint entityId,
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(
entityId,
registration,
@ -2887,7 +3116,11 @@ public sealed class ShadowObjectRegistry
rows,
_suspendedEntities.Contains(entityId),
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;
}
@ -2912,6 +3145,32 @@ public sealed class ShadowObjectRegistry
for (int entryIndex = 0; entryIndex < row.Entries.Length; entryIndex++)
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);
}
@ -3112,12 +3371,30 @@ public sealed class ShadowObjectRegistry
IReadOnlyList<PreparedShadowCellRows> Rows,
bool Suspended,
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(
uint CellId,
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>
/// Retires the complete logical registry at terminal physics-engine
/// disposal, including suspended live registrations that own no cell row.

View file

@ -360,4 +360,208 @@ public class ShadowObjectRegistryRetailCellArrayTests
Assert.Equal(0x7003u, entries[1].GfxObjId);
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);
}
}