feat(physics): S2 chunk 1a - retail cell array and per-part entries beside the collision flood

Campaign OVERHAUL S2 chunk 1 (Core half). ShadowObjectRegistry accepts the
object's whole visual part array as an optional registration input and
computes retail's CELLARRAY from it as a retained side product:

- route per CPhysicsObj::calc_cross_cells_static @0x00515160: cylsphere
  (CObjCell::find_cell_list @0x0052b9f0 over the Setup's authored
  cylspheres, carried as the Cylinder shapes of the COLLISION dispatch) when
  (state & 0x10000) == 0 and a cylsphere exists, else the bbox route
  (find_bbox_cell_list @0x00510fc0 via CellTransit.BuildShadowCellSetFromParts
  over every visual part: physics sphere else drawing sphere, part box);
- per-cell part entries in array-then-part order for every part
  (CPartArray::AddPartsShadow @0x00517e40), clip planes required iff the
  array has more than one cell;
- Deregister clears everything (remove_shadows_from_cells @0x00511230);
  UpdatePosition/CommitSetPosition recompute; ReplaceMultiPartPayload swaps
  the part array without re-flooding.

_entityToCells and _cells are untouched; no production caller passes the
part array yet (chunk 1b), so this is a zero-behavior change. The lead
corrected the implementer's route decision, which had inspected the visual
part array for cylinders; retail's GetNumCylsphere reads Setup collision
data. Core Physics 2,202/2,202.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-09-02 21:20:40 +02:00
parent 015b660d0f
commit 059b8883ab
3 changed files with 788 additions and 6 deletions

View file

@ -114,6 +114,19 @@ internal sealed class CollisionWorldState
internal Dictionary<uint, int> ShadowOwnerIndices { get; } = new();
internal Stack<int> ShadowOwnerFreeSlots { get; } = new();
// ── Campaign OVERHAUL S2 chunk 1 ────────────────────────────────────────
// Retail's whole-part-array CELLARRAY (CPhysicsObj::calc_cross_cells_static
// 0x00515160) and its sibling per-cell part-entry product
// (CPartArray::AddPartsShadow 0x00517e40), retained beside the existing
// ShadowEntity* fields. A NEW product only: nothing above touches it,
// and nothing in production reads it yet (see ShadowObjectRegistry).
internal Dictionary<uint, IReadOnlyList<ShadowShape>>
ShadowEntityRetailPartArrays { get; } = new();
internal Dictionary<uint, List<uint>> ShadowEntityRetailCellArrays { get; } = new();
internal Dictionary<uint, RetailCellArrayRoute>
ShadowEntityRetailCellArrayRoutes { get; } = new();
internal Dictionary<uint, List<RetailPartEntry>> RetailPartEntriesByCell { get; } = new();
// ── O1 per-prefix installed-key ledgers ────────────────────────────────
// Every mutation of the five landblock-scoped world maps goes through the
// typed helpers below so these ledgers stay exact. The seal's landblock-

View file

@ -49,6 +49,46 @@ public sealed class ShadowObjectRegistry
private Dictionary<uint, System.Collections.Generic.IReadOnlyList<ShadowShape>> _entityShapes =>
_collisionWorld.Current.ShadowEntityShapes;
/// <summary>
/// Campaign OVERHAUL S2 chunk 1: the caller-supplied WHOLE visual part
/// array behind <see cref="TryGetRetailCellArray"/> and
/// <see cref="GetRetailPartEntriesInCell"/> — retail's
/// <c>CPartArray.parts</c>/<c>num_parts</c> input to
/// <c>calc_cross_cells_static</c> (0x00515160). Distinct from
/// <see cref="_entityShapes"/>, which is the BSP-only COLLISION dispatch
/// (AP-152): retail's <c>CPartArray::AddPartsShadow</c> (0x00517e40)
/// registers EVERY visual part, colliding or not, so this list is
/// typically wider. Retained so <see cref="UpdatePosition"/> and
/// <see cref="CommitSetPosition"/> can recompute the retail products at
/// the new position without a caller re-supplying the part array on
/// every move.
/// </summary>
private Dictionary<uint, System.Collections.Generic.IReadOnlyList<ShadowShape>> _entityRetailPartArrays =>
_collisionWorld.Current.ShadowEntityRetailPartArrays;
/// <summary>
/// Retail's ordered CELLARRAY (Contract A) computed from
/// <see cref="_entityRetailPartArrays"/>. A retained SIDE PRODUCT ONLY —
/// distinct from <see cref="_entityToCells"/>, which stays the existing
/// collision-flood answer until S2 chunk 3 cuts collision over.
/// </summary>
private Dictionary<uint, List<uint>> _retailCellArrays =>
_collisionWorld.Current.ShadowEntityRetailCellArrays;
/// <summary>Which <c>calc_cross_cells_static</c> branch produced the
/// entry in <see cref="_retailCellArrays"/> (or the empty result).</summary>
private Dictionary<uint, RetailCellArrayRoute> _retailCellArrayRoutes =>
_collisionWorld.Current.ShadowEntityRetailCellArrayRoutes;
/// <summary>
/// Retail's per-cell <c>CPartArray::AddPartsShadow</c> (0x00517e40)
/// product: every crossed cell's part list, in CELLARRAY-then-part-array
/// order. Parallel to <see cref="_cells"/> but keyed by the SAME cell ids
/// as <see cref="_retailCellArrays"/>, not <see cref="_entityToCells"/>.
/// </summary>
private Dictionary<uint, List<RetailPartEntry>> _retailPartEntriesByCell =>
_collisionWorld.Current.RetailPartEntriesByCell;
/// <summary>
/// BR-7: per-entity registration arguments, kept so a registration can be
/// RE-RUN when more cells hydrate. Retail's equivalent is
@ -367,6 +407,14 @@ public sealed class ShadowObjectRegistry
/// them in separate cell lists (<c>shadow_part_list</c> versus
/// <c>shadow_object_list</c>). Decorative meshes therefore need this path
/// even when <see cref="GetOwnerCells"/> is empty.
///
/// <para>
/// Campaign OVERHAUL S2 chunk 1: <c>RecomputeRetailCellArray</c>'s bbox
/// route is this same primitive, called from the entity's own
/// registration transaction instead of a per-frame consumer. This
/// method is that recomputation's predecessor; S2 chunk 2 deletes it
/// once consumers cut over to <see cref="TryGetRetailCellArray"/>.
/// </para>
/// </summary>
public IReadOnlyList<uint> ComputeStaticRenderCells(
uint seedCellId,
@ -393,6 +441,259 @@ public sealed class ShadowObjectRegistry
isStatic: true);
}
/// <summary>
/// Retail's exact <c>CObjCell::find_cell_list</c>/<c>find_bbox_cell_list</c>
/// CELLARRAY (Contract A, re-verified 2026-09-01 through the live Ghidra
/// bridge at <c>127.0.0.1:8081</c> against <c>patchmem.gpr</c>) for one
/// entity's WHOLE part array, plus the sibling
/// <c>CPartArray::AddPartsShadow</c> (0x00517e40) per-cell part-entry
/// product, published into <see cref="_retailPartEntriesByCell"/>.
///
/// <para>
/// A CHUNK-1 SIDE PRODUCT ONLY (Campaign OVERHAUL S2 chunk 1,
/// `docs/research/2026-09-01-overhaul/s2-membership-ownership-map.md`
/// §3): it never writes <see cref="_entityToCells"/> or
/// <see cref="_cells"/>, and nothing in production reads it yet.
/// </para>
///
/// <para>
/// Route — <c>CPhysicsObj::calc_cross_cells_static</c> 0x00515160's
/// branch table:
/// </para>
/// <list type="bullet">
/// <item>cylsphere (<c>CObjCell::find_cell_list</c> 0x0052b9f0, over the
/// authored CylSpheres, reusing <see cref="BuildFloodSpheres"/> — "today's
/// cylinder registration") when <c>(state &amp; 0x10000) == 0</c> AND the
/// object's authored COLLISION dispatch (<paramref name="collisionShapes"/>,
/// retail <c>CPartArray::GetNumCylsphere() != 0</c>: the Setup's
/// CylSpheres) carries at least one
/// <see cref="ShadowCollisionType.Cylinder"/> shape. The visual part array
/// never decides the route: retail's cylspheres are Setup collision data,
/// not parts;</item>
/// <item>otherwise the bbox route (<c>CPhysicsObj::find_bbox_cell_list</c>
/// 0x00510fc0 via <see cref="CellTransit.BuildShadowCellSetFromParts"/>) —
/// every other case, including a BSP-bearing object (state bit set) and a
/// part array with no authored CylSpheres at all. This is the exact
/// primitive <see cref="ComputeStaticRenderCells"/> already uses.</item>
/// </list>
/// </summary>
private void RecomputeRetailCellArray(
uint entityId,
uint seedCellId,
Vector3 entityWorldPos,
Quaternion entityWorldRot,
uint state,
IReadOnlyList<ShadowShape> collisionShapes,
IReadOnlyList<ShadowShape> partArray,
bool isStatic)
{
ClearRetailCellArray(entityId);
if (partArray.Count == 0 || seedCellId == 0u)
return;
_entityRetailPartArrays[entityId] = partArray;
// Contract A: GetNumCylsphere() reads the Setup's authored CylSpheres,
// which acdream carries as the Cylinder shapes of the COLLISION
// dispatch — never as visual parts.
bool hasCylsphere = false;
for (int i = 0; i < collisionShapes.Count; i++)
{
if (collisionShapes[i].CollisionType == ShadowCollisionType.Cylinder)
{
hasCylsphere = true;
break;
}
}
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(
FloodCache, seedCellId, cylSpheres, cylSpheres.Count, isStatic);
}
else
{
route = RetailCellArrayRoute.BoundingBox;
List<ShadowPartBox> boxes =
BuildFloodPartBoxes(entityWorldPos, entityWorldRot, partArray);
List<DatReaderWriter.Types.Sphere> spheres =
BuildBspPartSpheres(entityWorldPos, entityWorldRot, partArray);
cellArray = CellTransit.BuildShadowCellSetFromParts(
FloodCache, seedCellId, boxes, spheres, isStatic);
}
// 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).
_retailCellArrayRoutes[entityId] = route;
if (cellArray.Count == 0)
return;
var orderedCells = new List<uint>(cellArray.Count);
for (int i = 0; i < cellArray.Count; i++)
orderedCells.Add(cellArray[i]);
_retailCellArrays[entityId] = orderedCells;
PublishRetailPartEntries(entityId, orderedCells, partArray);
}
/// <summary>
/// Recomputes <see cref="RecomputeRetailCellArray"/> at a new position
/// when — and only when — a retail part array is currently retained for
/// <paramref name="entityId"/>. No-op for every entity registered
/// without one (item E of the S2 chunk-1 contract: existing callers stay
/// byte-for-byte unaffected).
/// </summary>
private void RecomputeRetailCellArrayIfPresent(
uint entityId,
uint seedCellId,
Vector3 worldPosition,
Quaternion worldRotation,
uint state,
bool isStatic)
{
if (!_entityRetailPartArrays.TryGetValue(
entityId,
out IReadOnlyList<ShadowShape>? partArray)
|| partArray.Count == 0)
{
return;
}
// The retained multipart collision dispatch decides the route; a
// single-shape registration retains none, which can only be a
// non-cylsphere object here (cylinder singles never move).
IReadOnlyList<ShadowShape> collisionShapes =
_entityShapes.TryGetValue(entityId, out var retainedShapes)
? retainedShapes
: Array.Empty<ShadowShape>();
RecomputeRetailCellArray(
entityId, seedCellId, worldPosition, worldRotation, state,
collisionShapes, partArray, isStatic);
}
/// <summary>
/// Removes every retained retail-cell-array product for
/// <paramref name="entityId"/>: the part array, the route, the ordered
/// CELLARRAY, and every per-cell <see cref="RetailPartEntry"/> row it
/// published. The inverse of <see cref="RecomputeRetailCellArray"/>,
/// mirroring retail's <c>remove_shadows_from_cells</c> (0x00511230)
/// symmetry for this side product.
/// </summary>
private void ClearRetailCellArray(uint entityId)
{
if (_retailCellArrays.TryGetValue(entityId, out List<uint>? cells))
{
RemoveRetailPartEntriesFromCells(entityId, cells);
_retailCellArrays.Remove(entityId);
}
_retailCellArrayRoutes.Remove(entityId);
_entityRetailPartArrays.Remove(entityId);
}
/// <summary>Removes every <see cref="RetailPartEntry"/> owned by
/// <paramref name="entityId"/> from each of <paramref name="cellIds"/>,
/// reclaiming a cell's list once it is left empty.</summary>
private void RemoveRetailPartEntriesFromCells(
uint entityId,
List<uint> cellIds)
{
for (int i = 0; i < cellIds.Count; i++)
{
if (_retailPartEntriesByCell.TryGetValue(
cellIds[i],
out List<RetailPartEntry>? entries))
{
entries.RemoveAll(e => e.EntityId == entityId);
if (entries.Count == 0)
_retailPartEntriesByCell.Remove(cellIds[i]);
}
}
}
/// <summary>
/// Publishes retail's <c>CPartArray::AddPartsShadow</c> (0x00517e40) rows
/// for one entity: for every cell in <paramref name="orderedCells"/>, in
/// array order, for every part in <paramref name="partArray"/> order, one
/// <see cref="RetailPartEntry"/> — the exact retail insertion order.
/// <c>ClipPlanesRequired</c> mirrors <c>num_shadow_objects &gt; 1 ?
/// cell-&gt;clip_planes : null</c>.
/// </summary>
private void PublishRetailPartEntries(
uint entityId,
List<uint> orderedCells,
IReadOnlyList<ShadowShape> partArray)
{
bool clipPlanesRequired = orderedCells.Count > 1;
for (int cellIndex = 0; cellIndex < orderedCells.Count; cellIndex++)
{
uint cellId = orderedCells[cellIndex];
if (!_retailPartEntriesByCell.TryGetValue(
cellId,
out List<RetailPartEntry>? entries))
{
entries = new List<RetailPartEntry>();
_retailPartEntriesByCell[cellId] = entries;
}
for (int partIndex = 0; partIndex < partArray.Count; partIndex++)
{
entries.Add(new RetailPartEntry(
entityId,
partIndex,
partArray[partIndex].GfxObjId,
cellId,
clipPlanesRequired));
}
}
}
/// <summary>
/// The retail CELLARRAY retained for <paramref name="entityId"/> — see
/// <see cref="RecomputeRetailCellArray"/>. Returns <see langword="false"/>
/// when no retail part array was ever supplied for this entity (every
/// existing caller that omits the new trailing <c>partArray</c> parameter
/// on <see cref="Register"/>/<see cref="RegisterMultiPart"/>) or when the
/// last computed CELLARRAY came back empty.
/// </summary>
public bool TryGetRetailCellArray(
uint entityId,
out IReadOnlyList<uint> cells)
{
if (_retailCellArrays.TryGetValue(entityId, out List<uint>? list))
{
cells = list;
return true;
}
cells = Array.Empty<uint>();
return false;
}
/// <summary>
/// Retail's <c>CPartArray::AddPartsShadow</c> (0x00517e40) part list for
/// one cell, across every entity that crossed it — CELLARRAY-then-part
/// order per entity, registration order across entities. Empty when no
/// entity's retail part array reaches this cell.
/// </summary>
public IReadOnlyList<RetailPartEntry> GetRetailPartEntriesInCell(uint cellId) =>
_retailPartEntriesByCell.TryGetValue(cellId, out List<RetailPartEntry>? entries)
? entries
: Array.Empty<RetailPartEntry>();
/// <summary>
/// Which <c>calc_cross_cells_static</c> branch (Contract A) produced
/// <paramref name="entityId"/>'s retail CELLARRAY, or
/// <see cref="RetailCellArrayRoute.None"/> when no retail part array is
/// retained for it.
/// </summary>
public RetailCellArrayRoute GetRetailCellArrayRoute(uint entityId) =>
_retailCellArrayRoutes.TryGetValue(entityId, out RetailCellArrayRoute route)
? route
: RetailCellArrayRoute.None;
/// <summary>
/// Register a single-shape entity. <paramref name="seedCellId"/> is the
/// entity's <c>m_position.objcell_id</c> — the flood seed. Pass 0 to
@ -415,7 +716,8 @@ public sealed class ShadowObjectRegistry
EntityCollisionFlags flags = EntityCollisionFlags.None,
uint seedCellId = 0u,
bool isStatic = true,
bool publishMutation = true)
bool publishMutation = true,
IReadOnlyList<ShadowShape>? partArray = null)
{
// Flood FIRST: retail keeps the previous shadows when the new cell
// array would be empty (SetPositionInternal num_cells gate,
@ -453,6 +755,22 @@ public sealed class ShadowObjectRegistry
BumpOwnerVersion(entityId);
else
RefreshOwnerPrefixIndex(entityId);
// Campaign OVERHAUL S2 chunk 1: an untouched side product when no
// caller supplies a part array (every call site before chunk 1b).
if (partArray is not null)
{
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);
}
}
/// <summary>
@ -500,7 +818,8 @@ public sealed class ShadowObjectRegistry
float worldOffsetX, float worldOffsetY, uint landblockId,
uint seedCellId = 0u,
bool isStatic = false,
bool publishMutation = true)
bool publishMutation = true,
IReadOnlyList<ShadowShape>? partArray = null)
{
if (shapes.Count == 0) { Deregister(entityId); return; }
@ -581,6 +900,17 @@ public sealed class ShadowObjectRegistry
BumpOwnerVersion(entityId);
else
RefreshOwnerPrefixIndex(entityId);
// Campaign OVERHAUL S2 chunk 1: an untouched side product when no
// caller supplies a part array (every call site before chunk 1b).
// 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)
{
RecomputeRetailCellArray(
entityId, seed, entityWorldPos, entityWorldRot, state, shapes, partArray, isStatic);
}
}
/// <summary>
@ -603,7 +933,8 @@ public sealed class ShadowObjectRegistry
uint landblockId,
uint seedCellId = 0u,
bool isStatic = false,
bool suspendIfNew = false)
bool suspendIfNew = false,
IReadOnlyList<ShadowShape>? partArray = null)
{
if (!_entityReg.TryGetValue(entityId, out RegistrationRecord? prior)
|| !prior.IsMultiPart)
@ -621,7 +952,8 @@ public sealed class ShadowObjectRegistry
worldOffsetY,
landblockId,
seedCellId,
isStatic);
isStatic,
partArray: partArray);
if (suspendIfNew)
Suspend(entityId);
return;
@ -637,6 +969,21 @@ public sealed class ShadowObjectRegistry
Flags = flags,
};
// Campaign OVERHAUL S2 chunk 1: SetPart semantics apply here too —
// this replaces the retained part array WITHOUT re-flooding. It
// reuses the CURRENT retail CELLARRAY (if any) and only rewrites
// which parts occupy it, mirroring the collision payload swap below.
if (partArray is not null)
{
_entityRetailPartArrays[entityId] = partArray;
if (_retailCellArrays.TryGetValue(entityId, out List<uint>? retailCells)
&& retailCells.Count != 0)
{
RemoveRetailPartEntriesFromCells(entityId, retailCells);
PublishRetailPartEntries(entityId, retailCells, partArray);
}
}
if (suspended || !_entityToCells.TryGetValue(entityId, out List<uint>? cells))
{
BumpOwnerVersion(entityId);
@ -891,18 +1238,28 @@ public sealed class ShadowObjectRegistry
&& DeriveOutdoorSeed(worldPos, worldOffsetX, worldOffsetY, landblockId) == 0u)
return;
// Campaign OVERHAUL S2 chunk 1: forward the retained retail part
// array (if any) so the flood below recomputes it at the new
// seed/position, exactly as the existing collision flood does. Not
// retained for this entity → null → both calls stay pixel-for-pixel
// what they were before chunk 1.
_entityRetailPartArrays.TryGetValue(
entityId,
out IReadOnlyList<ShadowShape>? retainedPartArray);
if (reg.IsMultiPart && _entityShapes.TryGetValue(entityId, out var shapes))
{
RegisterMultiPart(entityId, worldPos, rotation, shapes,
reg.State, reg.Flags, worldOffsetX, worldOffsetY, landblockId,
seedCellId, reg.IsStatic);
seedCellId, reg.IsStatic, partArray: retainedPartArray);
return;
}
Register(entityId, reg.GfxObjId, worldPos, rotation, reg.Radius,
worldOffsetX, worldOffsetY, landblockId,
reg.CollisionType, reg.CylHeight, reg.Scale,
reg.State, reg.Flags, seedCellId, reg.IsStatic);
reg.State, reg.Flags, seedCellId, reg.IsStatic,
partArray: retainedPartArray);
}
/// <summary>
@ -1553,6 +1910,9 @@ public sealed class ShadowObjectRegistry
EntityWorldPos = worldPosition,
EntityWorldRot = worldRotation,
};
RecomputeRetailCellArrayIfPresent(
entityId, seedCellId, worldPosition, worldRotation,
registration.State, registration.IsStatic);
BumpOwnerVersion(entityId);
}
@ -1655,6 +2015,9 @@ public sealed class ShadowObjectRegistry
if (withdrawn.Count == 0)
_withdrawnPrefixesByOwner.Remove(entityId);
}
RecomputeRetailCellArrayIfPresent(
entityId, seedCellId, worldPosition, worldRotation,
registration.State, registration.IsStatic);
BumpOwnerVersion(entityId);
}
@ -1976,6 +2339,11 @@ public sealed class ShadowObjectRegistry
_suspendedEntities.Remove(entityId);
_suspendedEntityCells.Remove(entityId);
_withdrawnPrefixesByOwner.Remove(entityId);
// Campaign OVERHAUL S2 chunk 1: the retail cell-array side product
// is torn down symmetrically with every other per-entity row, both
// for a genuine Deregister and for the "clear then re-register"
// idiom Register/RegisterMultiPart use ahead of their own flood.
ClearRetailCellArray(entityId);
if (existed && publishMutation)
{
BumpOwnerVersion(entityId);
@ -2901,3 +3269,48 @@ public readonly record struct ShadowEntry(
// the shape sitting at the entity's origin.
Vector3 LocalPosition = default,
Quaternion LocalRotation = default);
/// <summary>
/// Campaign OVERHAUL S2 chunk 1: which branch of retail's
/// <c>CPhysicsObj::calc_cross_cells_static</c> (0x00515160) produced one
/// entity's <see cref="ShadowObjectRegistry.TryGetRetailCellArray"/> result.
/// </summary>
public enum RetailCellArrayRoute
{
/// <summary>No retail part array is retained for this entity.</summary>
None,
/// <summary>
/// <c>CObjCell::find_cell_list</c> (0x0052b9f0) over the object's
/// authored CylSpheres — taken when the cached
/// <c>HAS_PHYSICS_BSP_PS</c> state bit (0x10000) is clear AND the part
/// array carries at least one Cylinder shape.
/// </summary>
Cylsphere,
/// <summary>
/// <c>CPhysicsObj::find_bbox_cell_list</c> (0x00510fc0) over the whole
/// part array — every other case, including a BSP-bearing object (state
/// bit set) and a part array with no authored CylSpheres.
/// </summary>
BoundingBox,
}
/// <summary>
/// One row of retail's per-cell <c>CPartArray::AddPartsShadow</c>
/// (0x00517e40) render-shadow product: part <paramref name="PartIndex"/> of
/// entity <paramref name="EntityId"/>'s whole part array, registered into
/// <paramref name="CellId"/> — one member of that entity's retail CELLARRAY
/// (<see cref="ShadowObjectRegistry.TryGetRetailCellArray"/>).
/// <paramref name="ClipPlanesRequired"/> mirrors retail's
/// <c>num_shadow_objects &gt; 1 ? cell-&gt;clip_planes : null</c> selection —
/// true exactly when the owning entity's CELLARRAY has more than one member.
/// Entries for one cell are retained in CELLARRAY-then-part-array order,
/// matching retail's insertion order.
/// </summary>
public readonly record struct RetailPartEntry(
uint EntityId,
int PartIndex,
uint GfxObjId,
uint CellId,
bool ClipPlanesRequired);