feat(physics): S2 chunk 4 - movement publishes from the transition's cells; children inherit at registration
Movement: CommitSetPosition's RefreshPositionRows/ReplacePositionRows and the staged apply publish the retail render product from the exact cell list collision just used, the transition's cell_array retail feeds add_shadows_to_cells in CPhysicsObj::SetPositionInternal @0x00515330 (pseudo-C 283526-283539); the separate move-path bbox recompute is deleted. calc_cross_cells @0x00515230 stays the distinct full-recompute path (PhysicsShadowCommitAction.Recalculate). Children (Contract B recursion): ShadowObjectRegistry.AttachChild/DetachChild give an attached object the root's current cells as part entries only, republished whenever the root's array changes, detached at withdrawal and cascaded from the root's Deregister; nested attachment resolves to the root with a bounded, cycle-safe chain. EquippedChildRenderController attaches at realization (FromSetupRenderParts over the child's Setup) and detaches at its single removal funnel. WalkProductionWorldData's dynamic sweep reads TryGetRetailCellArray directly; the 64-hop parent-chain walk and its FindParentLocalId plumbing are deleted. CollisionWorldState.Clear now also clears the retail products. Gates (implementer's isolated worktree at identical content): Release build 0/0; Core 4,970/4,970; App hermetic 6,761/6,761; targeted walk/child/live-entity/placement/comparator 166/166; Runtime 1,884/1,884. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
706fc49397
commit
75ea269d35
14 changed files with 1072 additions and 179 deletions
|
|
@ -294,8 +294,17 @@ public class ShadowObjectRegistryRetailCellArrayTests
|
|||
e => e.EntityId == entityId);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Campaign OVERHAUL S2 chunk 4: "one array, two products" on the move
|
||||
// path too. Retail's SetPositionInternal (pc:283399) republishes BOTH
|
||||
// the collision CShadowObj list and the retail render product from the
|
||||
// SAME transition cell_array (pc:283536-283537) — never a second,
|
||||
// independently-flooded array. The retail array must therefore equal
|
||||
// GetOwnerCells after every CommitSetPosition variant.
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void CommitSetPosition_WithRetainedPartArray_RecomputesRetailCellArray()
|
||||
public void CommitSetPosition_NoneAction_PublishesRetailProductFromTheRetainedCells()
|
||||
{
|
||||
var reg = new ShadowObjectRegistry();
|
||||
const uint entityId = 0x16u;
|
||||
|
|
@ -307,7 +316,13 @@ public class ShadowObjectRegistryRetailCellArrayTests
|
|||
Assert.True(reg.TryGetRetailCellArray(entityId, out var before));
|
||||
Assert.Equal(new[] { CellA }, before);
|
||||
|
||||
var moved = new Vector3(42f, 12f, 50f); // -> CellB
|
||||
// A .None commit carries no transition cell_array of its own (it
|
||||
// maps to retail's "cell did not change" branch, e.g. ForceIntoCell
|
||||
// with changedCell == false) — CommitSetPosition's None action
|
||||
// republishes at the RETAINED cells (CellA), not a fresh flood from
|
||||
// the new world position, even though (42,12,50) would geometrically
|
||||
// resolve to CellB. Both products must agree either way.
|
||||
var moved = new Vector3(42f, 12f, 50f);
|
||||
reg.CommitSetPosition(
|
||||
entityId,
|
||||
moved,
|
||||
|
|
@ -318,8 +333,72 @@ public class ShadowObjectRegistryRetailCellArrayTests
|
|||
action: PhysicsShadowCommitAction.None,
|
||||
crossCellIds: ImmutableArray<uint>.Empty);
|
||||
|
||||
Assert.Equal(new[] { CellA }, reg.GetOwnerCells(entityId));
|
||||
Assert.True(reg.TryGetRetailCellArray(entityId, out var after));
|
||||
Assert.Equal(new[] { CellB }, after);
|
||||
Assert.Equal(reg.GetOwnerCells(entityId), after);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CommitSetPosition_ReplaceAction_PublishesRetailProductFromTheTransitionCellArray()
|
||||
{
|
||||
var reg = new ShadowObjectRegistry();
|
||||
const uint entityId = 0x18u;
|
||||
IReadOnlyList<ShadowShape> parts = new[] { Cyl(0x6101u) };
|
||||
|
||||
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);
|
||||
|
||||
// .Replace supplies the transition's own crossCellIds — retail's
|
||||
// arg2->cell_array. Both products move to it directly; there is no
|
||||
// second, independently-computed cell array to disagree with it.
|
||||
var moved = new Vector3(42f, 12f, 50f);
|
||||
reg.CommitSetPosition(
|
||||
entityId,
|
||||
moved,
|
||||
Quaternion.Identity,
|
||||
seedCellId: CellB,
|
||||
worldOffsetX: OffX,
|
||||
worldOffsetY: OffY,
|
||||
action: PhysicsShadowCommitAction.Replace,
|
||||
crossCellIds: [CellB]);
|
||||
|
||||
Assert.Equal(new[] { CellB }, reg.GetOwnerCells(entityId));
|
||||
Assert.True(reg.TryGetRetailCellArray(entityId, out var after));
|
||||
Assert.Equal(reg.GetOwnerCells(entityId), after);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CommitSetPosition_KeepWhenEmpty_LeavesBothProductsUntouched()
|
||||
{
|
||||
var reg = new ShadowObjectRegistry();
|
||||
const uint entityId = 0x19u;
|
||||
IReadOnlyList<ShadowShape> parts = new[] { Cyl(0x6201u) };
|
||||
|
||||
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);
|
||||
|
||||
// Retail's num_cells > 0 gate (pc:283534/pc:283540): an EMPTY
|
||||
// transition cell_array leaves the previous shadows exactly as they
|
||||
// were for BOTH products — no republish of either.
|
||||
reg.CommitSetPosition(
|
||||
entityId,
|
||||
Pos,
|
||||
Quaternion.Identity,
|
||||
seedCellId: CellA,
|
||||
worldOffsetX: OffX,
|
||||
worldOffsetY: OffY,
|
||||
action: PhysicsShadowCommitAction.Preserve,
|
||||
crossCellIds: ImmutableArray<uint>.Empty);
|
||||
|
||||
Assert.Equal(new[] { CellA }, reg.GetOwnerCells(entityId));
|
||||
Assert.True(reg.TryGetRetailCellArray(entityId, out var after));
|
||||
Assert.Equal(new[] { CellA }, after);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
|
|
@ -564,4 +643,228 @@ public class ShadowObjectRegistryRetailCellArrayTests
|
|||
Assert.Single(partEntries);
|
||||
Assert.Equal(0x8101u, partEntries[0].GfxObjId);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Campaign OVERHAUL S2 chunk 4: AttachChild/DetachChild — retail's
|
||||
// add_shadows_to_cells child-inheritance recursion (Contract B). An
|
||||
// attached projection (equipped weapon/shield/ammunition) owns no
|
||||
// independent collision shapes; it inherits the root's CELLARRAY and
|
||||
// gets PART ENTRIES only, never a collision row.
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
private static IReadOnlyList<ShadowShape> ChildParts(params uint[] gfxObjIds)
|
||||
{
|
||||
var shapes = new ShadowShape[gfxObjIds.Length];
|
||||
for (int i = 0; i < gfxObjIds.Length; i++)
|
||||
shapes[i] = Bsp(gfxObjIds[i]);
|
||||
return shapes;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AttachChild_GivesChildTheRootsArrayAndEntries()
|
||||
{
|
||||
var reg = new ShadowObjectRegistry();
|
||||
const uint rootId = 0x40u;
|
||||
const uint childId = 0x41u;
|
||||
IReadOnlyList<ShadowShape> rootParts = new[] { Cyl(0x9001u) };
|
||||
|
||||
reg.RegisterMultiPart(rootId, Pos, Quaternion.Identity, rootParts,
|
||||
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
|
||||
partArray: rootParts);
|
||||
Assert.True(reg.TryGetRetailCellArray(rootId, out var rootCells));
|
||||
Assert.Equal(new[] { CellA }, rootCells);
|
||||
|
||||
IReadOnlyList<ShadowShape> childParts = ChildParts(0xC001u, 0xC002u);
|
||||
Assert.True(reg.AttachChild(childId, rootId, childParts));
|
||||
|
||||
Assert.True(reg.TryGetRetailCellArray(childId, out var childCells));
|
||||
Assert.Equal(rootCells, childCells);
|
||||
|
||||
var entries = reg.GetRetailPartEntriesInCell(CellA)
|
||||
.Where(e => e.EntityId == childId)
|
||||
.OrderBy(e => e.PartIndex)
|
||||
.ToList();
|
||||
Assert.Equal(2, entries.Count);
|
||||
Assert.Equal(0xC001u, entries[0].GfxObjId);
|
||||
Assert.Equal(0xC002u, entries[1].GfxObjId);
|
||||
|
||||
// Never a collision row — acdream's attached projections own none.
|
||||
Assert.Empty(reg.GetOwnerCells(childId));
|
||||
Assert.DoesNotContain(
|
||||
reg.GetObjectsInCell(CellA),
|
||||
e => e.EntityId == childId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AttachChild_RootMove_RepublishesTheChild()
|
||||
{
|
||||
var reg = new ShadowObjectRegistry();
|
||||
const uint rootId = 0x42u;
|
||||
const uint childId = 0x43u;
|
||||
IReadOnlyList<ShadowShape> rootParts = new[] { Cyl(0x9101u) };
|
||||
|
||||
reg.RegisterMultiPart(rootId, Pos, Quaternion.Identity, rootParts,
|
||||
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
|
||||
isStatic: false, partArray: rootParts);
|
||||
Assert.True(reg.AttachChild(childId, rootId, ChildParts(0xC101u)));
|
||||
Assert.True(reg.TryGetRetailCellArray(childId, out var before));
|
||||
Assert.Equal(new[] { CellA }, before);
|
||||
|
||||
var moved = new Vector3(42f, 12f, 50f); // -> CellB
|
||||
reg.CommitSetPosition(
|
||||
rootId,
|
||||
moved,
|
||||
Quaternion.Identity,
|
||||
seedCellId: CellB,
|
||||
worldOffsetX: OffX,
|
||||
worldOffsetY: OffY,
|
||||
action: PhysicsShadowCommitAction.Replace,
|
||||
crossCellIds: [CellB]);
|
||||
|
||||
Assert.True(reg.TryGetRetailCellArray(rootId, out var rootAfter));
|
||||
Assert.Equal(new[] { CellB }, rootAfter);
|
||||
Assert.True(reg.TryGetRetailCellArray(childId, out var childAfter));
|
||||
Assert.Equal(rootAfter, childAfter);
|
||||
|
||||
Assert.DoesNotContain(
|
||||
reg.GetRetailPartEntriesInCell(CellA),
|
||||
e => e.EntityId == childId);
|
||||
var movedEntries = reg.GetRetailPartEntriesInCell(CellB)
|
||||
.Where(e => e.EntityId == childId).ToList();
|
||||
Assert.Single(movedEntries);
|
||||
Assert.Equal(0xC101u, movedEntries[0].GfxObjId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DetachChild_ClearsTheChildsProducts()
|
||||
{
|
||||
var reg = new ShadowObjectRegistry();
|
||||
const uint rootId = 0x44u;
|
||||
const uint childId = 0x45u;
|
||||
IReadOnlyList<ShadowShape> rootParts = new[] { Cyl(0x9201u) };
|
||||
|
||||
reg.RegisterMultiPart(rootId, Pos, Quaternion.Identity, rootParts,
|
||||
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
|
||||
partArray: rootParts);
|
||||
Assert.True(reg.AttachChild(childId, rootId, ChildParts(0xC201u)));
|
||||
Assert.True(reg.TryGetRetailCellArray(childId, out _));
|
||||
|
||||
Assert.True(reg.DetachChild(childId));
|
||||
|
||||
Assert.False(reg.TryGetRetailCellArray(childId, out var afterCells));
|
||||
Assert.Empty(afterCells);
|
||||
Assert.DoesNotContain(
|
||||
reg.GetRetailPartEntriesInCell(CellA),
|
||||
e => e.EntityId == childId);
|
||||
// The root itself is untouched.
|
||||
Assert.True(reg.TryGetRetailCellArray(rootId, out var rootCells));
|
||||
Assert.Equal(new[] { CellA }, rootCells);
|
||||
|
||||
// A second detach is a no-op, not an error.
|
||||
Assert.False(reg.DetachChild(childId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deregister_RootCascadesDetachOfAttachedChildren()
|
||||
{
|
||||
var reg = new ShadowObjectRegistry();
|
||||
const uint rootId = 0x46u;
|
||||
const uint childId = 0x47u;
|
||||
IReadOnlyList<ShadowShape> rootParts = new[] { Cyl(0x9301u) };
|
||||
|
||||
reg.RegisterMultiPart(rootId, Pos, Quaternion.Identity, rootParts,
|
||||
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
|
||||
partArray: rootParts);
|
||||
Assert.True(reg.AttachChild(childId, rootId, ChildParts(0xC301u)));
|
||||
|
||||
reg.Deregister(rootId);
|
||||
|
||||
Assert.False(reg.TryGetRetailCellArray(rootId, out _));
|
||||
Assert.False(reg.TryGetRetailCellArray(childId, out var childCells));
|
||||
Assert.Empty(childCells);
|
||||
Assert.DoesNotContain(
|
||||
reg.GetRetailPartEntriesInCell(CellA),
|
||||
e => e.EntityId == childId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AttachChild_NestedChildOfAChild_ResolvesToTheRoot()
|
||||
{
|
||||
var reg = new ShadowObjectRegistry();
|
||||
const uint rootId = 0x48u;
|
||||
const uint directChildId = 0x49u;
|
||||
const uint grandchildId = 0x4Au;
|
||||
IReadOnlyList<ShadowShape> rootParts = new[] { Cyl(0x9401u) };
|
||||
|
||||
reg.RegisterMultiPart(rootId, Pos, Quaternion.Identity, rootParts,
|
||||
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
|
||||
partArray: rootParts);
|
||||
Assert.True(reg.AttachChild(directChildId, rootId, ChildParts(0xC401u)));
|
||||
// Attach the grandchild to the DIRECT CHILD, not the root — nested
|
||||
// attachment must still resolve to the ultimate root's array.
|
||||
Assert.True(reg.AttachChild(grandchildId, directChildId, ChildParts(0xC402u)));
|
||||
|
||||
Assert.True(reg.TryGetRetailCellArray(rootId, out var rootCells));
|
||||
Assert.True(reg.TryGetRetailCellArray(grandchildId, out var grandchildCells));
|
||||
Assert.Equal(rootCells, grandchildCells);
|
||||
|
||||
var entries = reg.GetRetailPartEntriesInCell(CellA)
|
||||
.Where(e => e.EntityId == grandchildId).ToList();
|
||||
Assert.Single(entries);
|
||||
Assert.Equal(0xC402u, entries[0].GfxObjId);
|
||||
|
||||
// Moving the root republishes BOTH the direct child and the
|
||||
// grandchild, in child-list order.
|
||||
var moved = new Vector3(42f, 12f, 50f);
|
||||
reg.CommitSetPosition(
|
||||
rootId, moved, Quaternion.Identity, seedCellId: CellB,
|
||||
worldOffsetX: OffX, worldOffsetY: OffY,
|
||||
action: PhysicsShadowCommitAction.Replace, crossCellIds: [CellB]);
|
||||
|
||||
Assert.True(reg.TryGetRetailCellArray(grandchildId, out var grandchildAfter));
|
||||
Assert.Equal(new[] { CellB }, grandchildAfter);
|
||||
|
||||
// Detaching the direct child cascades to the grandchild too.
|
||||
Assert.True(reg.DetachChild(directChildId));
|
||||
Assert.False(reg.TryGetRetailCellArray(grandchildId, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AttachChild_SelfAttachRejected()
|
||||
{
|
||||
var reg = new ShadowObjectRegistry();
|
||||
const uint entityId = 0x4Bu;
|
||||
IReadOnlyList<ShadowShape> rootParts = new[] { Cyl(0x9501u) };
|
||||
|
||||
reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, rootParts,
|
||||
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
|
||||
partArray: rootParts);
|
||||
|
||||
Assert.False(reg.AttachChild(entityId, entityId, ChildParts(0xC501u)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AttachChild_CycleRejected_RootUnaffected()
|
||||
{
|
||||
var reg = new ShadowObjectRegistry();
|
||||
const uint rootId = 0x4Cu;
|
||||
const uint childId = 0x4Du;
|
||||
IReadOnlyList<ShadowShape> rootParts = new[] { Cyl(0x9601u) };
|
||||
|
||||
reg.RegisterMultiPart(rootId, Pos, Quaternion.Identity, rootParts,
|
||||
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
|
||||
partArray: rootParts);
|
||||
Assert.True(reg.AttachChild(childId, rootId, ChildParts(0xC601u)));
|
||||
Assert.True(reg.TryGetRetailCellArray(rootId, out var rootCellsBefore));
|
||||
|
||||
// Attaching the root TO its own child would form a cycle.
|
||||
Assert.False(reg.AttachChild(rootId, childId, ChildParts(0xC602u)));
|
||||
|
||||
Assert.True(reg.TryGetRetailCellArray(rootId, out var rootCellsAfter));
|
||||
Assert.Equal(rootCellsBefore, rootCellsAfter);
|
||||
Assert.Equal(new[] { CellA }, reg.GetOwnerCells(rootId));
|
||||
// The child's own attachment is unaffected by the rejected call.
|
||||
Assert.True(reg.TryGetRetailCellArray(childId, out var childCells));
|
||||
Assert.Equal(rootCellsAfter, childCells);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue