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:
Erik 2026-09-02 23:37:34 +02:00
parent 706fc49397
commit 75ea269d35
14 changed files with 1072 additions and 179 deletions

View file

@ -1388,7 +1388,9 @@ public sealed class LiveEntityNetworkOnPositionCollapseMatrixTests
static (_, _, _) =>
new ExactProjectionWithdrawalOutcome(
ExactProjectionWithdrawalDisposition.Superseded,
null)),
null),
Shadows,
new PhysicsDataCache()),
new ProjectileController(Runtime),
animatedEntities,
new RemoteMovementObservationTracker(),

View file

@ -653,7 +653,9 @@ public sealed class LiveEntityNetworkRemoteTeleportPresentationTests
static (_, _, _) =>
new ExactProjectionWithdrawalOutcome(
ExactProjectionWithdrawalDisposition.Superseded,
null)),
null),
Shadows,
new PhysicsDataCache()),
new ProjectileController(Runtime),
animatedEntities,
new RemoteMovementObservationTracker(),

View file

@ -9,6 +9,7 @@ using AcDream.Content;
using AcDream.Core.Items;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using AcDream.Core.World;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
@ -1767,7 +1768,9 @@ public sealed class EquippedChildProjectionWithdrawalTests
Live,
Poses,
update => Live.TryApplyParent(update, out _),
withdraw);
withdraw,
new ShadowObjectRegistry(),
new PhysicsDataCache());
_lifecycle.Bind(new DelegateLiveEntityRuntimeComponentLifecycle(
Controller.OnLogicalTeardown));
}

View file

@ -231,16 +231,56 @@ public sealed class WalkProductionWorldDataTests
Assert.Empty(outdoor);
}
// Campaign OVERHAUL S2 chunk 4: ResolveDynamicRenderCells is now a direct
// TryGetRetailCellArray read for EVERY dynamic record, children included
// — ShadowObjectRegistry.AttachChild owns Contract B's child-inheritance
// recursion at the registry, so the render-side parent-chain walk this
// test used to exercise no longer exists.
private static ShadowShape ChildBsp(uint gfxObjId) =>
ShadowShape.Bsp(
gfxObjId,
Vector3.Zero,
Quaternion.Identity,
scale: 1f,
localGeometry: ShadowPartGeometry.Create(
new FlatCollisionSphere(Vector3.Zero, 1f), null));
[Fact]
public void ResolveDynamicRenderCells_EquippedDescendantInheritsRootCellArray()
public void ResolveDynamicRenderCells_AttachedChildReadsTheRegistrysRetailCellArrayDirectly()
{
var shadows = new ShadowObjectRegistry();
const uint rootId = 0x5000000Au;
const uint childId = 0x800045EEu;
IReadOnlyList<ShadowShape> rootParts = new[] { ChildBsp(0x02000001u) };
shadows.RegisterMultiPart(
rootId,
new Vector3(12f, 12f, 50f),
Quaternion.Identity,
rootParts,
state: 0u,
flags: EntityCollisionFlags.None,
worldOffsetX: 0f,
worldOffsetY: 0f,
landblockId: 0xF4180000u,
seedCellId: 0xF4180104u,
isStatic: false,
partArray: rootParts);
Assert.True(shadows.TryGetRetailCellArray(rootId, out IReadOnlyList<uint> rootCells));
Assert.NotEmpty(rootCells); // the fixture must actually exercise a flood
// No render-side parent walk any more: Contract B's inheritance is
// owned by the registry at attach time.
Assert.True(shadows.AttachChild(childId, rootId, new[] { ChildBsp(0x02000002u) }));
RenderProjectionRecord wand = Record(
id: 0x800045EEu,
id: childId,
position: Vector3.Zero) with
{
Source = new RenderSourceMetadata() with
{
LocalEntityId = 0x800045EEu,
LocalEntityId = childId,
ParentCellId = 0xF4180104u,
},
EntityPayload = new RenderEntityPayload() with
@ -248,27 +288,66 @@ public sealed class WalkProductionWorldDataTests
CasterIdentity = RenderCasterIdentityKind.EquippedChild,
},
};
var owners = new Dictionary<uint, IReadOnlyList<uint>>
{
[0x5000000Au] = [0xF4180106u, 0xF4180104u],
};
var parents = new Dictionary<uint, uint>
{
[0x800045EEu] = 0x80000461u,
[0x80000461u] = 0x5000000Au,
};
IReadOnlyList<uint> cells =
WalkProductionWorldData.ResolveDynamicRenderCells(
in wand,
id => owners.TryGetValue(id, out IReadOnlyList<uint>? value)
? value
: Array.Empty<uint>(),
id => parents.TryGetValue(id, out uint value)
? value
: null);
id => shadows.TryGetRetailCellArray(id, out IReadOnlyList<uint> c)
? (true, c)
: (false, Array.Empty<uint>()),
shadows.GetOwnerCells,
out bool usedFallback);
Assert.Equal([0xF4180106u, 0xF4180104u], cells);
Assert.False(usedFallback);
Assert.Equal(rootCells, cells);
}
[Fact]
public void ResolveDynamicRenderCells_UnregisteredEntityFallsBackToOwnerCells()
{
var shadows = new ShadowObjectRegistry();
const uint entityId = 0x00099Fu;
IReadOnlyList<ShadowShape> shapes = new[] { ChildBsp(0x02000003u) };
shadows.RegisterMultiPart(
entityId,
new Vector3(12f, 12f, 50f),
Quaternion.Identity,
shapes,
state: 0u,
flags: EntityCollisionFlags.None,
worldOffsetX: 0f,
worldOffsetY: 0f,
landblockId: 0xF4180000u,
seedCellId: 0xF4180104u,
isStatic: false);
// No partArray supplied above -> no retail cell array registered for
// this entity (item E of the S2 chunk-1 contract), so the ordinary
// collision-flood answer is the conservative fallback.
Assert.False(shadows.TryGetRetailCellArray(entityId, out _));
Assert.NotEmpty(shadows.GetOwnerCells(entityId));
RenderProjectionRecord record = Record(
id: entityId,
position: Vector3.Zero) with
{
Source = new RenderSourceMetadata() with
{
LocalEntityId = entityId,
ParentCellId = 0xF4180104u,
},
};
IReadOnlyList<uint> cells =
WalkProductionWorldData.ResolveDynamicRenderCells(
in record,
id => shadows.TryGetRetailCellArray(id, out IReadOnlyList<uint> c)
? (true, c)
: (false, Array.Empty<uint>()),
shadows.GetOwnerCells,
out bool usedFallback);
Assert.True(usedFallback);
Assert.Equal(shadows.GetOwnerCells(entityId), cells);
}
// -----------------------------------------------------------------

View file

@ -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);
}
}