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 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-
|
||||
|
|
|
|||
|
|
@ -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 & 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>
|
||||
/// 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 > 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