feat(physics): S2 chunk 3 - one flood per registration feeds collision rows and render entries

ShadowObjectRegistry computes the CELLARRAY once per registration under
Contract A (cylsphere route from the Setup's collision cylspheres, else the
bbox route over the whole visual part array) and derives BOTH products from
it: the collision rows (_entityToCells/_cells, CShadowObj per cell via
add_shadows_to_cells @0x00514ae0) and the per-part render entries
(AddPartsShadow @0x00517e40). The second, independent collision flood is
gone. A caller that supplies no part array floods from its collision shapes
exactly as before, so every legacy expectation holds byte-for-byte.

The staged SetPosition pipeline now carries the retail part array, cell
array, route, and entries through TryCaptureOwnerState/InstallOwnerState
and publishes them beside the collision cell replacements, honoring the
keep-when-empty rule (SetPositionInternal num_cells gate, pc:283540) for
both products together; two new tests pin a cross-cell move and the
keep-when-empty case. Behavior change, retail-exact: an object with
decorative non-BSP parts now has its collision shapes registered in every
cell those parts reach (pinned by a two-cell fixture); all-BSP objects are
unchanged. Movement paths still take collision cells from the transition
and recompute the retail product separately; chunk 4 unifies them on the
transition's array as retail does. No particle emitter reaches this registry.

Gates (implementer's isolated worktree at identical content): Release
build 0/0; Core 4,961/4,961; App hermetic 6,760/6,760; collision/InstalledDat
fixtures 63/63; Runtime 1,884/1,884; Content 213/213.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-09-02 23:02:47 +02:00
parent d79058cec4
commit afbd241016
4 changed files with 665 additions and 85 deletions

View file

@ -360,4 +360,208 @@ public class ShadowObjectRegistryRetailCellArrayTests
Assert.Equal(0x7003u, entries[1].GfxObjId);
Assert.DoesNotContain(entries, e => e.GfxObjId == 0x7001u);
}
// -------------------------------------------------------------------
// Campaign OVERHAUL S2 chunk 3: one flood per registration. Both
// _entityToCells/_cells (collision) and the retail render product now
// come from the SAME CELLARRAY when a caller supplies partArray — a
// decorative non-BSP-colliding visual part widens COLLISION membership
// too, not just the retail product. The BSP test still decides contact
// at query time; only membership (which cells hold a row at all) widens.
// -------------------------------------------------------------------
[Fact]
public void RegisterMultiPart_DecorativePartArrayEntry_WidensCollisionRowsToo()
{
var reg = new ShadowObjectRegistry();
const uint entityId = 0x20u;
const uint collidingGfxObj = 0xAAAA0001u;
const uint decorativeGfxObj = 0xBBBB0001u;
// The entity's actual COLLISION geometry is a single BSP part at the
// entity's own origin (CellA only, on its own — AP-152's exclusive
// collision dispatch).
IReadOnlyList<ShadowShape> collisionShapes = new[] { Bsp(collidingGfxObj) };
// Its WHOLE VISUAL part array (chunk 1b's partArray input) additionally
// carries a decorative part 30 m away with no collision of its own.
// ShadowShapeBuilder.FromStaticRenderParts/FromSetupRenderParts tag
// EVERY visual part CollisionType.BSP as a pure geometry carrier
// regardless of whether its GfxObj has a real physics BSP, so this
// mirrors production exactly.
IReadOnlyList<ShadowShape> partArray = new[]
{
Bsp(collidingGfxObj),
Bsp(decorativeGfxObj, new Vector3(30f, 0f, 0f)),
};
reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, collisionShapes,
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
partArray: partArray);
// Retail CELLARRAY reaches both cells — the decorative part's box
// pulls in CellB even though it cannot collide.
Assert.True(reg.TryGetRetailCellArray(entityId, out var retailCells));
Assert.Equal(new[] { CellA, CellB }, retailCells);
Assert.Equal(RetailCellArrayRoute.BoundingBox, reg.GetRetailCellArrayRoute(entityId));
// Chunk 3: the SAME array now drives collision. Retail == collision
// by construction, not coincidence.
Assert.Equal(retailCells, reg.GetOwnerCells(entityId));
foreach (uint cellId in new[] { CellA, CellB })
{
var collisionRows = reg.GetObjectsInCell(cellId)
.Where(e => e.EntityId == entityId)
.ToList();
Assert.Single(collisionRows);
Assert.Equal(collidingGfxObj, collisionRows[0].GfxObjId);
// The decorative part never appears as a COLLISION row (AP-152:
// the collision dispatch stays BSP-exclusive over collisionShapes)
// — only as a retail render part entry.
Assert.DoesNotContain(collisionRows, e => e.GfxObjId == decorativeGfxObj);
var partEntries = reg.GetRetailPartEntriesInCell(cellId)
.Where(e => e.EntityId == entityId)
.Select(e => e.GfxObjId)
.ToList();
Assert.Contains(collidingGfxObj, partEntries);
Assert.Contains(decorativeGfxObj, partEntries);
}
}
[Fact]
public void RegisterMultiPart_WithoutDecorativeParts_RetailArrayStillEqualsCollisionCells()
{
// BSP-only shapes with partArray == shapes: retail array and
// collision cells must agree exactly, whether or not a partArray is
// supplied at all (item F: "registering with partArray and without
// must produce identical _entityToCells for BSP-only shapes").
var withPartArray = new ShadowObjectRegistry();
var withoutPartArray = new ShadowObjectRegistry();
const uint entityId = 0x21u;
IReadOnlyList<ShadowShape> shapes = new[] { Bsp(0x0100_0055u, radius: 2f) };
withPartArray.RegisterMultiPart(entityId, Pos, Quaternion.Identity, shapes,
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
seedCellId: CellA, isStatic: true, partArray: shapes);
withoutPartArray.RegisterMultiPart(entityId, Pos, Quaternion.Identity, shapes,
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
seedCellId: CellA, isStatic: true);
Assert.Equal(
withoutPartArray.GetOwnerCells(entityId),
withPartArray.GetOwnerCells(entityId));
Assert.True(withPartArray.TryGetRetailCellArray(entityId, out var retailCells));
Assert.Equal(withPartArray.GetOwnerCells(entityId), retailCells);
}
// -------------------------------------------------------------------
// Campaign OVERHAUL S2 chunk 3 item B: the staged
// TryPrepareSetPosition/TryApplySetPosition pipeline must carry the
// retail render product exactly like the direct CommitSetPosition path
// — the chunk-1 gap where InstallOwnerState never seeded the staging
// registry's retail dictionaries, so its own internal Contract-A
// recompute silently no-opped and the applied commit dropped the retail
// product on the floor.
// -------------------------------------------------------------------
[Fact]
public void StagedSetPosition_WithRetainedPartArray_MovesBothProductsTogether()
{
var reg = new ShadowObjectRegistry();
const uint entityId = 0x30u;
IReadOnlyList<ShadowShape> parts = new[] { Cyl(0x8001u) };
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);
Assert.Equal(new[] { CellA }, reg.GetOwnerCells(entityId));
var moved = new Vector3(42f, 12f, 50f); // 30 m away in X -> CellB
Assert.True(reg.TryPrepareSetPosition(
entityId,
moved,
Quaternion.Identity,
seedCellId: CellB,
worldOffsetX: OffX,
worldOffsetY: OffY,
PhysicsShadowCommitAction.Replace,
crossCellIds: [CellB],
provenShapeless: false,
suspendOwner: false,
out var prepared));
// Not yet applied — both products still describe the OLD position.
Assert.Equal(new[] { CellA }, reg.GetOwnerCells(entityId));
Assert.True(reg.TryGetRetailCellArray(entityId, out var stillBefore));
Assert.Equal(new[] { CellA }, stillBefore);
Assert.True(reg.TryApplySetPosition(prepared!, out var receipt));
Assert.True(receipt.Mutated);
Assert.Equal(new[] { CellB }, reg.GetOwnerCells(entityId));
Assert.True(reg.TryGetRetailCellArray(entityId, out var after));
Assert.Equal(new[] { CellB }, after);
Assert.Equal(RetailCellArrayRoute.Cylsphere, reg.GetRetailCellArrayRoute(entityId));
var partEntries = reg.GetRetailPartEntriesInCell(CellB)
.Where(e => e.EntityId == entityId).ToList();
Assert.Single(partEntries);
Assert.Equal(0x8001u, partEntries[0].GfxObjId);
Assert.DoesNotContain(
reg.GetRetailPartEntriesInCell(CellA), e => e.EntityId == entityId);
var collisionRows = reg.GetObjectsInCell(CellB)
.Where(e => e.EntityId == entityId).ToList();
Assert.Single(collisionRows);
Assert.Equal(0x8001u, collisionRows[0].GfxObjId);
Assert.DoesNotContain(
reg.GetObjectsInCell(CellA), e => e.EntityId == entityId);
}
[Fact]
public void StagedSetPosition_KeepWhenEmpty_PreservesBothProductsTogether()
{
var reg = new ShadowObjectRegistry();
const uint entityId = 0x31u;
IReadOnlyList<ShadowShape> parts = new[] { Cyl(0x8101u) };
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);
Assert.Equal(new[] { CellA }, reg.GetOwnerCells(entityId));
// seedCellId: 0 with the Recalculate action resolves NO seed at all
// (landblockId derives from seedCellId, so it is also 0 —
// DeriveOutdoorSeed's own guard) — retail keeps the previous
// shadows (SetPositionInternal num_cells gate, pc:283540). The
// staged pipeline must honor this for BOTH products, not just
// collision.
Assert.True(reg.TryPrepareSetPosition(
entityId,
new Vector3(999f, 999f, 50f),
Quaternion.Identity,
seedCellId: 0u,
worldOffsetX: OffX,
worldOffsetY: OffY,
PhysicsShadowCommitAction.Recalculate,
crossCellIds: ImmutableArray<uint>.Empty,
provenShapeless: false,
suspendOwner: false,
out var prepared));
Assert.True(reg.TryApplySetPosition(prepared!, out _));
Assert.Equal(new[] { CellA }, reg.GetOwnerCells(entityId));
Assert.True(reg.TryGetRetailCellArray(entityId, out var after));
Assert.Equal(new[] { CellA }, after);
Assert.Equal(RetailCellArrayRoute.Cylsphere, reg.GetRetailCellArrayRoute(entityId));
var partEntries = reg.GetRetailPartEntriesInCell(CellA)
.Where(e => e.EntityId == entityId).ToList();
Assert.Single(partEntries);
Assert.Equal(0x8101u, partEntries[0].GfxObjId);
}
}