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

@ -34,6 +34,28 @@ public sealed class EquippedChildRenderController : IDisposable
_withdrawProjection;
private readonly EntityEffectPoseRegistry _poses;
/// <summary>
/// Campaign OVERHAUL S2 chunk 4: the SAME canonical shadow registry
/// <c>LiveEntityCollisionBuilder</c> publishes into — retail's
/// <c>add_shadows_to_cells</c> child-inheritance recursion (Contract B)
/// needs an attached child's visual part array registered against its
/// accepted parent's CURRENT retail CELLARRAY. Attached projections
/// (equipped weapons/shields/ammunition) never carry independent
/// collision shapes, so this owner ONLY calls
/// <see cref="ShadowObjectRegistry.AttachChild"/>/<see cref="ShadowObjectRegistry.DetachChild"/>
/// — never Register/RegisterMultiPart.
/// </summary>
private readonly ShadowObjectRegistry _shadows;
/// <summary>
/// Campaign OVERHAUL S2 chunk 4: the SAME <c>PhysicsDataCache</c> resolver
/// pair <see cref="AcDream.Runtime.Physics.LiveEntityCollisionBuilder"/>
/// uses for <c>ShadowShapeBuilder.FromSetupRenderParts</c> — a static and
/// a live Setup sharing a GfxObj id can never disagree about its render
/// geometry.
/// </summary>
private readonly PhysicsDataCache _physicsData;
private ParentAttachmentState Relations => _liveEntities.ParentAttachments;
/// <summary>Raised after the attached projection is fully registered.</summary>
@ -98,7 +120,9 @@ public sealed class EquippedChildRenderController : IDisposable
EntityEffectPoseRegistry poses,
Func<ParentEvent.Parsed, bool> acceptParent,
Func<LiveEntityRecord, ulong, ulong, ExactProjectionWithdrawalOutcome>
withdrawProjection)
withdrawProjection,
ShadowObjectRegistry shadows,
PhysicsDataCache physicsData)
{
_dats = dats ?? throw new ArgumentNullException(nameof(dats));
_datLock = datLock ?? throw new ArgumentNullException(nameof(datLock));
@ -108,6 +132,8 @@ public sealed class EquippedChildRenderController : IDisposable
_acceptParent = acceptParent ?? throw new ArgumentNullException(nameof(acceptParent));
_withdrawProjection = withdrawProjection
?? throw new ArgumentNullException(nameof(withdrawProjection));
_shadows = shadows ?? throw new ArgumentNullException(nameof(shadows));
_physicsData = physicsData ?? throw new ArgumentNullException(nameof(physicsData));
_parentOfAttached = static child => child.ParentRecord.ProjectionKey;
_tickAttached = TickChild;
_reconcileAttached = ReconcileChild;
@ -659,6 +685,15 @@ public sealed class EquippedChildRenderController : IDisposable
CaptureParentPresentation(attached, parentEntity);
RuntimeEntityKey childKey = RequireProjectionKey(childRecord);
_attachedByChild[childKey] = attached;
// Campaign OVERHAUL S2 chunk 4: register the retail render-shadow
// inheritance (Contract B) at the exact moment the attached
// projection's WorldEntity is registered against its accepted
// parent — retail's add_shadows_to_cells child recursion, ported at
// the registry rather than reconstructed at render/bucket time.
_shadows.AttachChild(
entity.Id,
parentEntity.Id,
BuildChildRenderParts(childSetup, template, scale));
_pendingUnparentByChild.Remove(childKey);
if ((parentRecord.FinalPhysicsState & PhysicsStateFlags.Hidden) != 0)
{
@ -1147,6 +1182,38 @@ public sealed class EquippedChildRenderController : IDisposable
return available;
}
/// <summary>
/// Campaign OVERHAUL S2 chunk 4: retail's per-part render membership
/// (<c>CPartArray::AddPartsShadow</c> 0x00517e40 walks EVERY visual
/// part, not just the BSP-only collision dispatch) for one attached
/// child, via <see cref="ShadowShapeBuilder.FromSetupRenderParts"/> — the
/// SAME builder and the SAME <see cref="_physicsData"/> resolver pair
/// <c>LiveEntityCollisionBuilder.Build</c> uses for an ordinary live
/// Setup, so a static, a live entity, and an attached child sharing a
/// GfxObj id can never disagree about its render geometry.
/// <paramref name="template"/>'s per-part GfxObj identity (post-
/// AnimPartChanged, <see cref="BuildPartTemplate"/>) supplies
/// <c>effectivePartGfxObjIds</c>; the child's own attach-composed pose
/// carries no per-part local-position meaning for THIS product — retail
/// never re-floods to place a child, it only inherits the root's
/// CELLARRAY (Contract B) — so no pose override is threaded here,
/// matching <c>FromSetupRenderParts</c>'s own placement-frame fallback.
/// </summary>
private IReadOnlyList<ShadowShape> BuildChildRenderParts(
Setup setup, IReadOnlyList<MeshRef> template, float scale)
{
var effectiveGfxObjIds = new uint[template.Count];
for (int i = 0; i < template.Count; i++)
effectiveGfxObjIds[i] = template[i].GfxObjId;
return ShadowShapeBuilder.FromSetupRenderParts(
setup,
scale,
effectiveGfxObjIds,
partPoseOverride: null,
_physicsData.GetGfxObj,
_physicsData.GetVisualBounds);
}
private static PaletteOverride? BuildPaletteOverride(WorldSession.EntitySpawn spawn)
{
if (spawn.SubPalettes is not { Count: > 0 } subPalettes)
@ -1388,6 +1455,12 @@ public sealed class EquippedChildRenderController : IDisposable
}
_attachedByChild.Remove(key);
// Campaign OVERHAUL S2 chunk 4: this is the single funnel every
// withdrawal/unparent/teardown path (WithdrawAttachedProjection,
// AdvanceUnparentTransition, TearDownRecordProjections, the pending-
// subtree captures) commits through — the exact mirror of
// AttachChild above.
_shadows.DetachChild(child.Entity.Id);
ProjectionRemoved?.Invoke(child.ChildRecord);
return true;
}

View file

@ -78,8 +78,7 @@ internal sealed class RetailPViewRenderer
Walk.WalkBuildingRegistry walkBuildings,
Walk.WalkLandscapeAssembler walkLandscape,
CellVisibility walkCellRegistry,
ShadowObjectRegistry shadows,
Func<uint, uint?>? findParentLocalId = null)
ShadowObjectRegistry shadows)
{
_renderSceneShadow = renderSceneShadow
?? throw new ArgumentNullException(nameof(renderSceneShadow));
@ -91,8 +90,7 @@ internal sealed class RetailPViewRenderer
?? throw new ArgumentNullException(nameof(walkCellRegistry));
_walkWorldData = new Walk.WalkProductionWorldData(
_walkBuildings,
shadows ?? throw new ArgumentNullException(nameof(shadows)),
findParentLocalId);
shadows ?? throw new ArgumentNullException(nameof(shadows)));
_walkClearInteriorDepthAction = ClearWalkInteriorDepth;
_walkDrawExitSealsAction = DrawWalkExitSeals;
}

View file

@ -78,7 +78,6 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
{
private readonly WalkBuildingRegistry _buildings;
private readonly ShadowObjectRegistry _shadows;
private readonly Func<uint, uint?> _findParentLocalId;
private RenderSceneQuery _scene;
private uint _tupleLandblockId;
private int _renderCenterLbX;
@ -107,17 +106,13 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
internal WalkProductionWorldData(
WalkBuildingRegistry buildings,
ShadowObjectRegistry shadows,
Func<uint, uint?>? findParentLocalId = null)
ShadowObjectRegistry shadows)
{
_buildings = buildings ?? throw new ArgumentNullException(nameof(buildings));
_shadows = shadows ?? throw new ArgumentNullException(nameof(shadows));
_findParentLocalId = findParentLocalId ?? NoParentLocalId;
_tryGetRetailCellArray = TryGetRetailCellArrayForEntity;
}
private static uint? NoParentLocalId(uint _) => null;
private (bool Found, IReadOnlyList<uint> Cells) TryGetRetailCellArrayForEntity(uint entityId)
{
bool found = _shadows.TryGetRetailCellArray(entityId, out IReadOnlyList<uint> cells);
@ -264,8 +259,11 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
ref readonly RenderProjectionRecord record = ref _dynamicSweepScratch[i];
IReadOnlyList<uint> renderCells = ResolveDynamicRenderCells(
in record,
_tryGetRetailCellArray,
_shadows.GetOwnerCells,
_findParentLocalId);
out bool usedFallback);
if (usedFallback)
UnregisteredStaticRenderFallbackCount++;
BucketDynamicRecord(
in record,
renderCells,
@ -277,48 +275,39 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
}
/// <summary>
/// Retail <c>CPhysicsObj::add_shadows_to_cells</c> recursively passes the
/// root object's same CELLARRAY to every child in its CHILDLIST. Attached
/// projections deliberately own no independent collision rows in acdream,
/// so walk their accepted parent chain until the root's retained array is
/// found. Ordinary dynamics continue to consume only their own rows.
/// Campaign OVERHAUL S2 chunk 4: one dynamic record's render-cell
/// membership, borrowed from <see cref="ShadowObjectRegistry.TryGetRetailCellArray"/>
/// — the SAME direct read <see cref="ResolveOutdoorStaticRenderCells"/>
/// uses. An equipped child's retail array is now published by
/// <see cref="ShadowObjectRegistry.AttachChild"/> at the registry (retail
/// Contract B's <c>add_shadows_to_cells</c> child-inheritance recursion),
/// so this no longer needs its own render-side parent-chain walk — the
/// registry already resolved a nested attachment to its ultimate root.
/// When the registry has no retail array yet for this entity (the same
/// streaming-window race <see cref="ResolveIndoorStaticRenderCells"/>
/// documents), the fallback is today's collision-flood
/// <see cref="ShadowObjectRegistry.GetOwnerCells"/> answer, counted the
/// same way as the static fallbacks via <paramref name="usedFallback"/>.
/// </summary>
internal static IReadOnlyList<uint> ResolveDynamicRenderCells(
in RenderProjectionRecord record,
Func<uint, (bool Found, IReadOnlyList<uint> Cells)> tryGetRetailCellArray,
Func<uint, IReadOnlyList<uint>> getOwnerCells,
Func<uint, uint?> findParentLocalId)
out bool usedFallback)
{
ArgumentNullException.ThrowIfNull(tryGetRetailCellArray);
ArgumentNullException.ThrowIfNull(getOwnerCells);
ArgumentNullException.ThrowIfNull(findParentLocalId);
uint current = record.Source.LocalEntityId;
IReadOnlyList<uint> cells = getOwnerCells(current);
if (cells.Count > 0
|| record.EntityPayload.CasterIdentity
!= RenderCasterIdentityKind.EquippedChild)
(bool found, IReadOnlyList<uint> cells) =
tryGetRetailCellArray(record.Source.LocalEntityId);
if (found)
{
usedFallback = false;
return cells;
}
// ParentAttachmentState rejects cycles. Keep a hard bound here so a
// corrupted diagnostic callback still cannot stall a render frame.
for (int depth = 0; depth < 64; depth++)
{
uint? parent = findParentLocalId(current);
if (parent is not { } parentId
|| parentId == 0u
|| parentId == current)
{
break;
}
current = parentId;
cells = getOwnerCells(current);
if (cells.Count > 0)
return cells;
}
return Array.Empty<uint>();
usedFallback = true;
return getOwnerCells(record.Source.LocalEntityId);
}
/// <summary>