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:
parent
d79058cec4
commit
afbd241016
4 changed files with 665 additions and 85 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue