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

@ -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.