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 Dictionary<uint, int> ShadowOwnerIndices { get; } = new();
internal Stack<int> ShadowOwnerFreeSlots { 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 ──────────────────────────────── // ── O1 per-prefix installed-key ledgers ────────────────────────────────
// Every mutation of the five landblock-scoped world maps goes through the // Every mutation of the five landblock-scoped world maps goes through the
// typed helpers below so these ledgers stay exact. The seal's landblock- // 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 => private Dictionary<uint, System.Collections.Generic.IReadOnlyList<ShadowShape>> _entityShapes =>
_collisionWorld.Current.ShadowEntityShapes; _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> /// <summary>
/// BR-7: per-entity registration arguments, kept so a registration can be /// BR-7: per-entity registration arguments, kept so a registration can be
/// RE-RUN when more cells hydrate. Retail's equivalent is /// 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 /// them in separate cell lists (<c>shadow_part_list</c> versus
/// <c>shadow_object_list</c>). Decorative meshes therefore need this path /// <c>shadow_object_list</c>). Decorative meshes therefore need this path
/// even when <see cref="GetOwnerCells"/> is empty. /// 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> /// </summary>
public IReadOnlyList<uint> ComputeStaticRenderCells( public IReadOnlyList<uint> ComputeStaticRenderCells(
uint seedCellId, uint seedCellId,
@ -393,6 +441,259 @@ public sealed class ShadowObjectRegistry
isStatic: true); 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> /// <summary>
/// Register a single-shape entity. <paramref name="seedCellId"/> is the /// Register a single-shape entity. <paramref name="seedCellId"/> is the
/// entity's <c>m_position.objcell_id</c> — the flood seed. Pass 0 to /// 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, EntityCollisionFlags flags = EntityCollisionFlags.None,
uint seedCellId = 0u, uint seedCellId = 0u,
bool isStatic = true, bool isStatic = true,
bool publishMutation = true) bool publishMutation = true,
IReadOnlyList<ShadowShape>? partArray = null)
{ {
// Flood FIRST: retail keeps the previous shadows when the new cell // Flood FIRST: retail keeps the previous shadows when the new cell
// array would be empty (SetPositionInternal num_cells gate, // array would be empty (SetPositionInternal num_cells gate,
@ -453,6 +755,22 @@ public sealed class ShadowObjectRegistry
BumpOwnerVersion(entityId); BumpOwnerVersion(entityId);
else else
RefreshOwnerPrefixIndex(entityId); 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> /// <summary>
@ -500,7 +818,8 @@ public sealed class ShadowObjectRegistry
float worldOffsetX, float worldOffsetY, uint landblockId, float worldOffsetX, float worldOffsetY, uint landblockId,
uint seedCellId = 0u, uint seedCellId = 0u,
bool isStatic = false, bool isStatic = false,
bool publishMutation = true) bool publishMutation = true,
IReadOnlyList<ShadowShape>? partArray = null)
{ {
if (shapes.Count == 0) { Deregister(entityId); return; } if (shapes.Count == 0) { Deregister(entityId); return; }
@ -581,6 +900,17 @@ public sealed class ShadowObjectRegistry
BumpOwnerVersion(entityId); BumpOwnerVersion(entityId);
else else
RefreshOwnerPrefixIndex(entityId); 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> /// <summary>
@ -603,7 +933,8 @@ public sealed class ShadowObjectRegistry
uint landblockId, uint landblockId,
uint seedCellId = 0u, uint seedCellId = 0u,
bool isStatic = false, bool isStatic = false,
bool suspendIfNew = false) bool suspendIfNew = false,
IReadOnlyList<ShadowShape>? partArray = null)
{ {
if (!_entityReg.TryGetValue(entityId, out RegistrationRecord? prior) if (!_entityReg.TryGetValue(entityId, out RegistrationRecord? prior)
|| !prior.IsMultiPart) || !prior.IsMultiPart)
@ -621,7 +952,8 @@ public sealed class ShadowObjectRegistry
worldOffsetY, worldOffsetY,
landblockId, landblockId,
seedCellId, seedCellId,
isStatic); isStatic,
partArray: partArray);
if (suspendIfNew) if (suspendIfNew)
Suspend(entityId); Suspend(entityId);
return; return;
@ -637,6 +969,21 @@ public sealed class ShadowObjectRegistry
Flags = flags, 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)) if (suspended || !_entityToCells.TryGetValue(entityId, out List<uint>? cells))
{ {
BumpOwnerVersion(entityId); BumpOwnerVersion(entityId);
@ -891,18 +1238,28 @@ public sealed class ShadowObjectRegistry
&& DeriveOutdoorSeed(worldPos, worldOffsetX, worldOffsetY, landblockId) == 0u) && DeriveOutdoorSeed(worldPos, worldOffsetX, worldOffsetY, landblockId) == 0u)
return; 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)) if (reg.IsMultiPart && _entityShapes.TryGetValue(entityId, out var shapes))
{ {
RegisterMultiPart(entityId, worldPos, rotation, shapes, RegisterMultiPart(entityId, worldPos, rotation, shapes,
reg.State, reg.Flags, worldOffsetX, worldOffsetY, landblockId, reg.State, reg.Flags, worldOffsetX, worldOffsetY, landblockId,
seedCellId, reg.IsStatic); seedCellId, reg.IsStatic, partArray: retainedPartArray);
return; return;
} }
Register(entityId, reg.GfxObjId, worldPos, rotation, reg.Radius, Register(entityId, reg.GfxObjId, worldPos, rotation, reg.Radius,
worldOffsetX, worldOffsetY, landblockId, worldOffsetX, worldOffsetY, landblockId,
reg.CollisionType, reg.CylHeight, reg.Scale, reg.CollisionType, reg.CylHeight, reg.Scale,
reg.State, reg.Flags, seedCellId, reg.IsStatic); reg.State, reg.Flags, seedCellId, reg.IsStatic,
partArray: retainedPartArray);
} }
/// <summary> /// <summary>
@ -1553,6 +1910,9 @@ public sealed class ShadowObjectRegistry
EntityWorldPos = worldPosition, EntityWorldPos = worldPosition,
EntityWorldRot = worldRotation, EntityWorldRot = worldRotation,
}; };
RecomputeRetailCellArrayIfPresent(
entityId, seedCellId, worldPosition, worldRotation,
registration.State, registration.IsStatic);
BumpOwnerVersion(entityId); BumpOwnerVersion(entityId);
} }
@ -1655,6 +2015,9 @@ public sealed class ShadowObjectRegistry
if (withdrawn.Count == 0) if (withdrawn.Count == 0)
_withdrawnPrefixesByOwner.Remove(entityId); _withdrawnPrefixesByOwner.Remove(entityId);
} }
RecomputeRetailCellArrayIfPresent(
entityId, seedCellId, worldPosition, worldRotation,
registration.State, registration.IsStatic);
BumpOwnerVersion(entityId); BumpOwnerVersion(entityId);
} }
@ -1976,6 +2339,11 @@ public sealed class ShadowObjectRegistry
_suspendedEntities.Remove(entityId); _suspendedEntities.Remove(entityId);
_suspendedEntityCells.Remove(entityId); _suspendedEntityCells.Remove(entityId);
_withdrawnPrefixesByOwner.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) if (existed && publishMutation)
{ {
BumpOwnerVersion(entityId); BumpOwnerVersion(entityId);
@ -2901,3 +3269,48 @@ public readonly record struct ShadowEntry(
// the shape sitting at the entity's origin. // the shape sitting at the entity's origin.
Vector3 LocalPosition = default, Vector3 LocalPosition = default,
Quaternion LocalRotation = 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);

View file

@ -0,0 +1,356 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Numerics;
using AcDream.Core.Physics;
using Xunit;
namespace AcDream.Core.Tests.Physics;
/// <summary>
/// Campaign OVERHAUL S2 chunk 1
/// (<c>docs/research/2026-09-01-overhaul/s2-membership-ownership-map.md</c>
/// §3): the retail CELLARRAY and per-cell part-entry SIDE PRODUCT
/// <see cref="ShadowObjectRegistry"/> now computes from an optional whole
/// part array, beside the existing collision flood. Retail anchors:
/// <c>CPhysicsObj::calc_cross_cells_static</c> 0x00515160 (Contract A branch
/// table), <c>CPhysicsObj::find_bbox_cell_list</c> 0x00510fc0,
/// <c>CPartArray::AddPartsShadow</c> 0x00517e40 (Contract B),
/// <c>CPhysicsObj::remove_shadows_from_cells</c> 0x00511230,
/// <c>CEnvCell::find_transit_cells</c> 0x0052cae0 — all re-verified
/// 2026-09-01 through the live Ghidra bridge against <c>patchmem.gpr</c>
/// (<c>docs/research/2026-09-01-overhaul/oh1-construction-landscape-contract.md</c>).
///
/// <para>
/// A bare <see cref="ShadowObjectRegistry"/> (no installed DAT) floods
/// against an empty <c>PhysicsDataCache</c>: an outdoor seed still produces
/// the real overlapped landcells from pure LandDefs math (the same fixture
/// style <see cref="BuildShadowCellSetTests"/> and
/// <c>ShadowObjectRegistryMultiPartTests</c> already use), so every fixture
/// here stays synthetic — no installed-DAT dependency.
/// </para>
/// </summary>
public class ShadowObjectRegistryRetailCellArrayTests
{
private const uint LbId = 0xA9B40000u;
private const float OffX = 0f;
private const float OffY = 0f;
// World (12, 12, 50) -> local cell (cx=0, cy=0) -> id = LbId | 1.
private const uint CellA = LbId | 1u;
// World (42, 12, 50), 30 m away in X -> local cell (cx=1, cy=0) -> id = LbId | 9.
private const uint CellB = LbId | 9u;
private static readonly Vector3 Pos = new(12f, 12f, 50f);
private static ShadowShape Bsp(uint gfxObjId, Vector3 localPosition = default, float radius = 1f)
=> ShadowShape.Bsp(
gfxObjId,
localPosition,
Quaternion.Identity,
scale: 1f,
localGeometry: ShadowPartGeometry.Create(
new FlatCollisionSphere(Vector3.Zero, radius), null));
private static ShadowShape Cyl(uint gfxObjId, Vector3 localPosition = default, float radius = 1f)
=> ShadowShape.Cylinder(
gfxObjId, localPosition, Quaternion.Identity, scale: 1f,
radius: radius, cylHeight: 1f);
// -------------------------------------------------------------------
// Item E: existing callers (no partArray) are byte-for-byte unaffected.
// -------------------------------------------------------------------
[Fact]
public void RegisterMultiPart_WithoutPartArray_NoRetailProduct()
{
var reg = new ShadowObjectRegistry();
const uint entityId = 0x10u;
var shapes = new[] { Bsp(0x0100_0001u) };
reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, shapes,
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId);
Assert.False(reg.TryGetRetailCellArray(entityId, out var cells));
Assert.Empty(cells);
Assert.Equal(RetailCellArrayRoute.None, reg.GetRetailCellArrayRoute(entityId));
foreach (uint cellId in reg.GetOwnerCells(entityId))
Assert.Empty(reg.GetRetailPartEntriesInCell(cellId));
}
[Fact]
public void Register_SingleShapeWithoutPartArray_NoRetailProduct()
{
var reg = new ShadowObjectRegistry();
const uint entityId = 0x11u;
reg.Register(entityId, 0x0100_0002u, Pos, Quaternion.Identity, 1f, OffX, OffY, LbId);
Assert.False(reg.TryGetRetailCellArray(entityId, out var cells));
Assert.Empty(cells);
Assert.Equal(RetailCellArrayRoute.None, reg.GetRetailCellArrayRoute(entityId));
}
// -------------------------------------------------------------------
// The bbox route is ComputeStaticRenderCells's own primitive, and
// supplying a part array never touches the existing collision product.
// -------------------------------------------------------------------
[Fact]
public void RegisterMultiPart_WithPartArray_BoundingBoxRouteMatchesComputeStaticRenderCells()
{
var withPartArray = new ShadowObjectRegistry();
var withoutPartArray = new ShadowObjectRegistry();
const uint entityId = 0x12u;
IReadOnlyList<ShadowShape> parts = new[] { Bsp(0x0100_0044u, radius: 2f) };
withPartArray.RegisterMultiPart(entityId, Pos, Quaternion.Identity, parts,
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
seedCellId: CellA, isStatic: true, partArray: parts);
withoutPartArray.RegisterMultiPart(entityId, Pos, Quaternion.Identity, parts,
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
seedCellId: CellA, isStatic: true);
Assert.Equal(RetailCellArrayRoute.BoundingBox, withPartArray.GetRetailCellArrayRoute(entityId));
Assert.True(withPartArray.TryGetRetailCellArray(entityId, out var retailCells));
IReadOnlyList<uint> expected =
withPartArray.ComputeStaticRenderCells(CellA, Pos, Quaternion.Identity, parts);
Assert.Equal(expected, retailCells);
Assert.NotEmpty(expected); // the fixture must actually exercise a flood
// Supplying a part array must not perturb the pre-existing collision
// product — same registration, same result either way.
Assert.Equal(withoutPartArray.GetOwnerCells(entityId), withPartArray.GetOwnerCells(entityId));
}
// -------------------------------------------------------------------
// Contract A branch table: (state & 0x10000) == 0 && has a Cylinder
// shape -> cylsphere route; otherwise (including the same part array
// with the state bit set) -> bbox route.
// -------------------------------------------------------------------
[Fact]
public void RegisterMultiPart_PartArrayRoute_DispatchesOnStateBitAndCylinderPresence()
{
var reg = new ShadowObjectRegistry();
const uint entityId = 0x13u;
IReadOnlyList<ShadowShape> mixed = new[]
{
Cyl(0x3001u), // part 0: at the entity origin (CellA)
Bsp(0x3002u, new Vector3(30f, 0f, 0f)), // part 1: 30 m away in X (CellB)
};
// State bit clear + a Cylinder present -> cylsphere route
// (CObjCell::find_cell_list 0x0052b9f0), flooded from the cylinder
// only — the bsp part 30 m away must not pull in CellB.
reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, mixed,
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId, partArray: mixed);
Assert.Equal(RetailCellArrayRoute.Cylsphere, reg.GetRetailCellArrayRoute(entityId));
Assert.True(reg.TryGetRetailCellArray(entityId, out var cylCells));
Assert.Equal(new[] { CellA }, cylCells);
var cylEntries = reg.GetRetailPartEntriesInCell(CellA)
.Where(e => e.EntityId == entityId)
.OrderBy(e => e.PartIndex)
.ToList();
// AddPartsShadow registers EVERY part per cell, not just the ones
// that contributed to crossing it (Contract B).
Assert.Equal(2, cylEntries.Count);
Assert.Equal(0, cylEntries[0].PartIndex);
Assert.Equal(0x3001u, cylEntries[0].GfxObjId);
Assert.Equal(1, cylEntries[1].PartIndex);
Assert.Equal(0x3002u, cylEntries[1].GfxObjId);
Assert.False(cylEntries[0].ClipPlanesRequired); // single-cell CELLARRAY
Assert.False(cylEntries[1].ClipPlanesRequired);
// Re-register the SAME part array with the HAS_PHYSICS_BSP_PS state
// bit set -> bbox route (CPhysicsObj::find_bbox_cell_list 0x00510fc0)
// regardless of the cylinder's presence, flooded from the surviving
// bsp part -> pulls in CellB too.
reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, mixed,
state: 0x10000u, flags: EntityCollisionFlags.None, OffX, OffY, LbId, partArray: mixed);
Assert.Equal(RetailCellArrayRoute.BoundingBox, reg.GetRetailCellArrayRoute(entityId));
Assert.True(reg.TryGetRetailCellArray(entityId, out var bboxCells));
Assert.Equal(new[] { CellA, CellB }, bboxCells);
foreach (uint cellId in bboxCells)
{
var entries = reg.GetRetailPartEntriesInCell(cellId)
.Where(e => e.EntityId == entityId)
.OrderBy(e => e.PartIndex)
.ToList();
Assert.Equal(2, entries.Count);
Assert.Equal(0, entries[0].PartIndex);
Assert.Equal(1, entries[1].PartIndex);
// More than one CELLARRAY member -> clip planes required (Contract B).
Assert.True(entries[0].ClipPlanesRequired);
Assert.True(entries[1].ClipPlanesRequired);
}
}
[Fact]
public void RegisterMultiPart_RouteIsDecidedByCollisionCylspheres_NotByTheVisualPartArray()
{
// Contract A: GetNumCylsphere() reads the Setup's authored CylSpheres,
// which acdream carries as the Cylinder shapes of the COLLISION
// dispatch. A visual part array (chunk 1b passes visual parts, which
// are never cylinders) must not change the route.
var reg = new ShadowObjectRegistry();
const uint entityId = 0x14u;
IReadOnlyList<ShadowShape> collision = new[] { Cyl(0x3001u) };
IReadOnlyList<ShadowShape> visualParts = new[]
{
Bsp(0x3002u, new Vector3(30f, 0f, 0f)), // a visual part 30 m away (CellB)
};
reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, collision,
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId, partArray: visualParts);
// Cylsphere route: flooded from the authored cylsphere at the origin,
// not from the far visual part.
Assert.Equal(RetailCellArrayRoute.Cylsphere, reg.GetRetailCellArrayRoute(entityId));
Assert.True(reg.TryGetRetailCellArray(entityId, out var cells));
Assert.Equal(new[] { CellA }, cells);
// ...but the entries are the VISUAL parts (AddPartsShadow walks the part array).
var entries = reg.GetRetailPartEntriesInCell(CellA).Where(e => e.EntityId == entityId).ToList();
Assert.Single(entries);
Assert.Equal(0x3002u, entries[0].GfxObjId);
// No cylsphere in the collision dispatch -> bbox route over the visual parts.
IReadOnlyList<ShadowShape> bspCollision = new[] { Bsp(0x3001u, Vector3.Zero) };
reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, bspCollision,
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId, partArray: visualParts);
Assert.Equal(RetailCellArrayRoute.BoundingBox, reg.GetRetailCellArrayRoute(entityId));
}
// -------------------------------------------------------------------
// Deregister is the exact inverse transaction.
// -------------------------------------------------------------------
[Fact]
public void Deregister_ClearsRetailCellArrayAndEveryPerCellEntry()
{
var reg = new ShadowObjectRegistry();
const uint entityId = 0x14u;
IReadOnlyList<ShadowShape> parts = new[]
{
Cyl(0x4001u),
Bsp(0x4002u, new Vector3(30f, 0f, 0f)),
};
reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, parts,
state: 0x10000u, flags: EntityCollisionFlags.None, OffX, OffY, LbId, partArray: parts);
Assert.True(reg.TryGetRetailCellArray(entityId, out var cellsBeforeRemoval));
Assert.NotEmpty(cellsBeforeRemoval);
var trackedCells = cellsBeforeRemoval.ToArray();
reg.Deregister(entityId);
Assert.False(reg.TryGetRetailCellArray(entityId, out var cellsAfterRemoval));
Assert.Empty(cellsAfterRemoval);
Assert.Equal(RetailCellArrayRoute.None, reg.GetRetailCellArrayRoute(entityId));
foreach (uint cellId in trackedCells)
Assert.Empty(reg.GetRetailPartEntriesInCell(cellId));
}
// -------------------------------------------------------------------
// UpdatePosition / CommitSetPosition recompute the retained product at
// the new seed/position — the same flood rules, run again.
// -------------------------------------------------------------------
[Fact]
public void UpdatePosition_WithRetainedPartArray_RecomputesRetailCellArray()
{
var reg = new ShadowObjectRegistry();
const uint entityId = 0x15u;
IReadOnlyList<ShadowShape> parts = new[] { Cyl(0x5001u) };
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);
var moved = new Vector3(42f, 12f, 50f); // 30 m away in X -> CellB
reg.UpdatePosition(entityId, moved, Quaternion.Identity, OffX, OffY, LbId);
Assert.True(reg.TryGetRetailCellArray(entityId, out var after));
Assert.Equal(new[] { CellB }, after);
var movedEntries = reg.GetRetailPartEntriesInCell(CellB)
.Where(e => e.EntityId == entityId).ToList();
Assert.Single(movedEntries);
Assert.Equal(0x5001u, movedEntries[0].GfxObjId);
Assert.DoesNotContain(
reg.GetRetailPartEntriesInCell(CellA),
e => e.EntityId == entityId);
}
[Fact]
public void CommitSetPosition_WithRetainedPartArray_RecomputesRetailCellArray()
{
var reg = new ShadowObjectRegistry();
const uint entityId = 0x16u;
IReadOnlyList<ShadowShape> parts = new[] { Cyl(0x6001u) };
reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, parts,
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
isStatic: false, partArray: parts);
Assert.True(reg.TryGetRetailCellArray(entityId, out var before));
Assert.Equal(new[] { CellA }, before);
var moved = new Vector3(42f, 12f, 50f); // -> CellB
reg.CommitSetPosition(
entityId,
moved,
Quaternion.Identity,
seedCellId: CellB,
worldOffsetX: OffX,
worldOffsetY: OffY,
action: PhysicsShadowCommitAction.None,
crossCellIds: ImmutableArray<uint>.Empty);
Assert.True(reg.TryGetRetailCellArray(entityId, out var after));
Assert.Equal(new[] { CellB }, after);
}
// -------------------------------------------------------------------
// ReplaceMultiPartPayload is retail's SetPart: it swaps which parts
// occupy the CURRENT CELLARRAY without re-flooding.
// -------------------------------------------------------------------
[Fact]
public void ReplaceMultiPartPayload_SwapsPartArrayWithoutReflooding()
{
var reg = new ShadowObjectRegistry();
const uint entityId = 0x17u;
IReadOnlyList<ShadowShape> initial = new[] { Cyl(0x7001u) };
reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, initial,
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId, partArray: initial);
Assert.True(reg.TryGetRetailCellArray(entityId, out var before));
Assert.Equal(new[] { CellA }, before);
Assert.Equal(RetailCellArrayRoute.Cylsphere, reg.GetRetailCellArrayRoute(entityId));
IReadOnlyList<ShadowShape> replacement = new[] { Cyl(0x7002u), Cyl(0x7003u) };
reg.ReplaceMultiPartPayload(entityId, Pos, Quaternion.Identity, replacement,
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId, partArray: replacement);
Assert.True(reg.TryGetRetailCellArray(entityId, out var after));
// No reflood: the CELLARRAY is unchanged (still CellA only).
Assert.Equal(before, after);
// The route stays whatever the LAST recompute (at registration)
// decided; ReplaceMultiPartPayload does not re-run the dispatch.
Assert.Equal(RetailCellArrayRoute.Cylsphere, reg.GetRetailCellArrayRoute(entityId));
var entries = reg.GetRetailPartEntriesInCell(CellA)
.Where(e => e.EntityId == entityId)
.OrderBy(e => e.PartIndex)
.ToList();
Assert.Equal(2, entries.Count);
Assert.Equal(0x7002u, entries[0].GfxObjId);
Assert.Equal(0x7003u, entries[1].GfxObjId);
Assert.DoesNotContain(entries, e => e.GfxObjId == 0x7001u);
}
}