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:
parent
015b660d0f
commit
059b8883ab
3 changed files with 788 additions and 6 deletions
|
|
@ -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-
|
||||||
|
|
|
||||||
|
|
@ -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 & 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 > 1 ?
|
||||||
|
/// cell->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 > 1 ? cell->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);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue