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
|
|
@ -507,8 +507,7 @@ internal sealed class FrameRootCompositionPhase
|
|||
?? throw new InvalidOperationException(
|
||||
"The retail frame walk requires the landscape registry."),
|
||||
d.CellVisibility,
|
||||
d.PhysicsEngine.ShadowObjects,
|
||||
live.EquippedChildren.FindParentLocalId),
|
||||
d.PhysicsEngine.ShadowObjects),
|
||||
retailPViewPassExecutor,
|
||||
retailPViewPassExecutor),
|
||||
retailPViewCells,
|
||||
|
|
|
|||
|
|
@ -622,7 +622,9 @@ internal sealed class LivePresentationCompositionPhase
|
|||
childRecord,
|
||||
positionVersion,
|
||||
projectionVersion,
|
||||
d.PlayerIdentity.ServerGuid)),
|
||||
d.PlayerIdentity.ServerGuid),
|
||||
d.PhysicsEngine.ShadowObjects,
|
||||
d.PhysicsDataCache),
|
||||
static value => value.Dispose());
|
||||
Fault(LivePresentationCompositionPoint.CorePresentationCreated);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -127,6 +127,17 @@ internal sealed class CollisionWorldState
|
|||
ShadowEntityRetailCellArrayRoutes { get; } = new();
|
||||
internal Dictionary<uint, List<RetailPartEntry>> RetailPartEntriesByCell { get; } = new();
|
||||
|
||||
// ── Campaign OVERHAUL S2 chunk 4 ────────────────────────────────────────
|
||||
// Retail's add_shadows_to_cells (0x00514ae0) child-inheritance recursion
|
||||
// (oh1-construction-landscape-contract.md Contract B): an attached
|
||||
// projection (equipped weapon/shield/ammunition) owns no independent
|
||||
// collision shapes and inherits its root's retail CELLARRAY instead of
|
||||
// flooding its own. See ShadowObjectRegistry.AttachChild/DetachChild.
|
||||
internal Dictionary<uint, uint> ShadowChildParent { get; } = new();
|
||||
internal Dictionary<uint, List<uint>> ShadowParentChildren { get; } = new();
|
||||
internal Dictionary<uint, IReadOnlyList<ShadowShape>>
|
||||
ShadowChildPartArrays { get; } = new();
|
||||
|
||||
// ── O1 per-prefix installed-key ledgers ────────────────────────────────
|
||||
// Every mutation of the five landblock-scoped world maps goes through the
|
||||
// typed helpers below so these ledgers stay exact. The seal's landblock-
|
||||
|
|
|
|||
|
|
@ -89,6 +89,27 @@ public sealed class ShadowObjectRegistry
|
|||
private Dictionary<uint, List<RetailPartEntry>> _retailPartEntriesByCell =>
|
||||
_collisionWorld.Current.RetailPartEntriesByCell;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign OVERHAUL S2 chunk 4: immediate parent (attachment point) for
|
||||
/// every entity attached via <see cref="AttachChild"/> — retail's
|
||||
/// <c>add_shadows_to_cells</c> child-inheritance recursion (Contract B).
|
||||
/// A nested attachment's ultimate root is resolved by walking this map;
|
||||
/// see <see cref="ResolveAttachRoot"/>.
|
||||
/// </summary>
|
||||
private Dictionary<uint, uint> _childParent =>
|
||||
_collisionWorld.Current.ShadowChildParent;
|
||||
|
||||
/// <summary>Direct children of one attachment point, in attach (child-list)
|
||||
/// order — the reverse index of <see cref="_childParent"/>.</summary>
|
||||
private Dictionary<uint, List<uint>> _parentChildren =>
|
||||
_collisionWorld.Current.ShadowParentChildren;
|
||||
|
||||
/// <summary>Each attached child's OWN visual part array (its equipped
|
||||
/// item's Setup parts), published into the root's cells by
|
||||
/// <see cref="PublishChildEntries"/> — never flooded independently.</summary>
|
||||
private Dictionary<uint, IReadOnlyList<ShadowShape>> _childPartArrays =>
|
||||
_collisionWorld.Current.ShadowChildPartArrays;
|
||||
|
||||
/// <summary>
|
||||
/// BR-7: per-entity registration arguments, kept so a registration can be
|
||||
/// RE-RUN when more cells hydrate. Retail's equivalent is
|
||||
|
|
@ -400,61 +421,6 @@ public sealed class ShadowObjectRegistry
|
|||
private PhysicsDataCache? _fallback;
|
||||
private PhysicsDataCache FloodCache => DataCache ?? _fallbackCache;
|
||||
|
||||
/// <summary>
|
||||
/// Retail's exact <c>CObjCell::find_cell_list</c>/<c>find_bbox_cell_list</c>
|
||||
/// CELLARRAY (Contract A, re-verified 2026-09-01 through the live Ghidra
|
||||
/// bridge at <c>127.0.0.1:8081</c> against <c>patchmem.gpr</c>) for one
|
||||
/// entity's WHOLE part array, plus the sibling
|
||||
/// <c>CPartArray::AddPartsShadow</c> (0x00517e40) per-cell part-entry
|
||||
/// product, published into <see cref="_retailPartEntriesByCell"/>.
|
||||
///
|
||||
/// <para>
|
||||
/// A CHUNK-1 SIDE PRODUCT ONLY (Campaign OVERHAUL S2 chunk 1,
|
||||
/// `docs/research/2026-09-01-overhaul/s2-membership-ownership-map.md`
|
||||
/// §3): it never writes <see cref="_entityToCells"/> or
|
||||
/// <see cref="_cells"/>, and nothing in production reads it yet.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Route — <c>CPhysicsObj::calc_cross_cells_static</c> 0x00515160's
|
||||
/// branch table:
|
||||
/// </para>
|
||||
/// <list type="bullet">
|
||||
/// <item>cylsphere (<c>CObjCell::find_cell_list</c> 0x0052b9f0, over the
|
||||
/// authored CylSpheres, reusing <see cref="BuildFloodSpheres"/> — "today's
|
||||
/// cylinder registration") when <c>(state & 0x10000) == 0</c> AND the
|
||||
/// object's authored COLLISION dispatch (<paramref name="collisionShapes"/>,
|
||||
/// retail <c>CPartArray::GetNumCylsphere() != 0</c>: the Setup's
|
||||
/// CylSpheres) carries at least one
|
||||
/// <see cref="ShadowCollisionType.Cylinder"/> shape. The visual part array
|
||||
/// never decides the route: retail's cylspheres are Setup collision data,
|
||||
/// not parts;</item>
|
||||
/// <item>otherwise the bbox route (<c>CPhysicsObj::find_bbox_cell_list</c>
|
||||
/// 0x00510fc0 via <see cref="CellTransit.BuildShadowCellSetFromParts"/>) —
|
||||
/// every other case, including a BSP-bearing object (state bit set) and a
|
||||
/// part array with no authored CylSpheres at all.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
private void RecomputeRetailCellArray(
|
||||
uint entityId,
|
||||
uint seedCellId,
|
||||
Vector3 entityWorldPos,
|
||||
Quaternion entityWorldRot,
|
||||
uint state,
|
||||
IReadOnlyList<ShadowShape> collisionShapes,
|
||||
IReadOnlyList<ShadowShape> partArray,
|
||||
bool isStatic)
|
||||
{
|
||||
ClearRetailCellArray(entityId);
|
||||
if (partArray.Count == 0 || seedCellId == 0u)
|
||||
return;
|
||||
|
||||
_entityRetailPartArrays[entityId] = partArray;
|
||||
(IReadOnlyList<uint> cellArray, RetailCellArrayRoute route) = ComputeContractACellArray(
|
||||
seedCellId, entityWorldPos, entityWorldRot, state, collisionShapes, partArray, isStatic);
|
||||
PublishRetailCellArray(entityId, cellArray, route, partArray);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign OVERHAUL S2 chunk 3: retail's <c>calc_cross_cells_static</c>
|
||||
/// (0x00515160) dispatch + flood, PURE — no registry state is read or
|
||||
|
|
@ -523,6 +489,16 @@ public sealed class ShadowObjectRegistry
|
|||
/// <c>remove_shadows_from_cells</c> symmetry for this side product. The
|
||||
/// route is recorded even when <paramref name="cellArray"/> is empty —
|
||||
/// the dispatch decision was still made.
|
||||
///
|
||||
/// <para>
|
||||
/// Campaign OVERHAUL S2 chunk 4: this is retail's ONE place a
|
||||
/// <c>CPhysicsObj</c>'s CELLARRAY changes, so it is also the one place
|
||||
/// that re-runs <see cref="RepublishAttachedChildren"/> — Contract B's
|
||||
/// "after the root, add_shadows_to_cells recursively passes the same
|
||||
/// CELLARRAY to every object in children". Harmless no-op on a scratch
|
||||
/// staging registry (<see cref="TryPrepareSetPosition"/>), which never
|
||||
/// carries any attach relationships of its own.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private void PublishRetailCellArray(
|
||||
uint entityId,
|
||||
|
|
@ -537,29 +513,43 @@ public sealed class ShadowObjectRegistry
|
|||
}
|
||||
_retailCellArrayRoutes[entityId] = route;
|
||||
if (cellArray.Count == 0)
|
||||
{
|
||||
RepublishAttachedChildren(entityId);
|
||||
return;
|
||||
}
|
||||
|
||||
var orderedCells = new List<uint>(cellArray.Count);
|
||||
for (int i = 0; i < cellArray.Count; i++)
|
||||
orderedCells.Add(cellArray[i]);
|
||||
_retailCellArrays[entityId] = orderedCells;
|
||||
PublishRetailPartEntries(entityId, orderedCells, partArray);
|
||||
RepublishAttachedChildren(entityId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recomputes <see cref="RecomputeRetailCellArray"/> at a new position
|
||||
/// when — and only when — a retail part array is currently retained for
|
||||
/// <paramref name="entityId"/>. No-op for every entity registered
|
||||
/// without one (item E of the S2 chunk-1 contract: existing callers stay
|
||||
/// byte-for-byte unaffected).
|
||||
/// Campaign OVERHAUL S2 chunk 4: publishes the retail render product from
|
||||
/// the SAME exact cell list the move-path collision republish
|
||||
/// (<see cref="ReplacePositionRows"/>) just used — no second,
|
||||
/// independently-flooded cell array. Retail's
|
||||
/// <c>CPhysicsObj::SetPositionInternal</c> (pc:283399) does exactly this
|
||||
/// on a successful transition: <c>add_shadows_to_cells(this,
|
||||
/// &arg2->cell_array)</c> (pc:283536-283537) consumes the
|
||||
/// TRANSITION's own array for BOTH the collision <c>CShadowObj</c> list
|
||||
/// and the render <c>shadow_part_list</c> in one call — there is no
|
||||
/// separate bbox/cylsphere dispatch on the move path
|
||||
/// (<c>calc_cross_cells_static</c> only runs at registration, or at a
|
||||
/// full recompute gated by the <c>HAS_PHYSICS_BSP_PS</c> state bit,
|
||||
/// pc:283528, which acdream maps to <see cref="PhysicsShadowCommitAction.Recalculate"/>
|
||||
/// → <see cref="UpdatePosition"/>, unaffected by this method).
|
||||
/// A no-op (existing rows retained, matching retail's <c>num_cells >
|
||||
/// 0</c> gate, pc:283540) when no retail part array is retained for this
|
||||
/// entity — item E of the S2 chunk-1 contract: a caller that never
|
||||
/// supplied a part array stays byte-for-byte unaffected on every move
|
||||
/// too.
|
||||
/// </summary>
|
||||
private void RecomputeRetailCellArrayIfPresent(
|
||||
private void PublishRetailProductFromExactCells(
|
||||
uint entityId,
|
||||
uint seedCellId,
|
||||
Vector3 worldPosition,
|
||||
Quaternion worldRotation,
|
||||
uint state,
|
||||
bool isStatic)
|
||||
IReadOnlyList<uint> exactCells)
|
||||
{
|
||||
if (!_entityRetailPartArrays.TryGetValue(
|
||||
entityId,
|
||||
|
|
@ -568,23 +558,23 @@ public sealed class ShadowObjectRegistry
|
|||
{
|
||||
return;
|
||||
}
|
||||
// The retained multipart collision dispatch decides the route; a
|
||||
// single-shape registration retains none, which can only be a
|
||||
// non-cylsphere object here (cylinder singles never move).
|
||||
IReadOnlyList<ShadowShape> collisionShapes =
|
||||
_entityShapes.TryGetValue(entityId, out var retainedShapes)
|
||||
? retainedShapes
|
||||
: Array.Empty<ShadowShape>();
|
||||
RecomputeRetailCellArray(
|
||||
entityId, seedCellId, worldPosition, worldRotation, state,
|
||||
collisionShapes, partArray, isStatic);
|
||||
// The route is retail's calc_cross_cells_static DISPATCH decision
|
||||
// (cylsphere vs bbox); the move path never re-runs that dispatch, so
|
||||
// the route recorded at the last actual dispatch (registration, or a
|
||||
// Recalculate-driven full recompute) still describes it exactly.
|
||||
RetailCellArrayRoute route = _retailCellArrayRoutes.TryGetValue(
|
||||
entityId,
|
||||
out RetailCellArrayRoute existingRoute)
|
||||
? existingRoute
|
||||
: RetailCellArrayRoute.None;
|
||||
PublishRetailCellArray(entityId, exactCells, route, partArray);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes every retained retail-cell-array product for
|
||||
/// <paramref name="entityId"/>: the part array, the route, the ordered
|
||||
/// CELLARRAY, and every per-cell <see cref="RetailPartEntry"/> row it
|
||||
/// published. The inverse of <see cref="RecomputeRetailCellArray"/>,
|
||||
/// published. The inverse of <see cref="PublishRetailCellArray"/>,
|
||||
/// mirroring retail's <c>remove_shadows_from_cells</c> (0x00511230)
|
||||
/// symmetry for this side product.
|
||||
/// </summary>
|
||||
|
|
@ -601,10 +591,12 @@ public sealed class ShadowObjectRegistry
|
|||
|
||||
/// <summary>Removes every <see cref="RetailPartEntry"/> owned by
|
||||
/// <paramref name="entityId"/> from each of <paramref name="cellIds"/>,
|
||||
/// reclaiming a cell's list once it is left empty.</summary>
|
||||
/// reclaiming a cell's list once it is left empty. Accepts
|
||||
/// <see cref="IReadOnlyList{T}"/> so an attached child can share the
|
||||
/// root's own cell list (chunk 4) without a defensive copy.</summary>
|
||||
private void RemoveRetailPartEntriesFromCells(
|
||||
uint entityId,
|
||||
List<uint> cellIds)
|
||||
IReadOnlyList<uint> cellIds)
|
||||
{
|
||||
for (int i = 0; i < cellIds.Count; i++)
|
||||
{
|
||||
|
|
@ -629,7 +621,7 @@ public sealed class ShadowObjectRegistry
|
|||
/// </summary>
|
||||
private void PublishRetailPartEntries(
|
||||
uint entityId,
|
||||
List<uint> orderedCells,
|
||||
IReadOnlyList<uint> orderedCells,
|
||||
IReadOnlyList<ShadowShape> partArray)
|
||||
{
|
||||
bool clipPlanesRequired = orderedCells.Count > 1;
|
||||
|
|
@ -657,7 +649,9 @@ public sealed class ShadowObjectRegistry
|
|||
|
||||
/// <summary>
|
||||
/// The retail CELLARRAY retained for <paramref name="entityId"/> — see
|
||||
/// <see cref="RecomputeRetailCellArray"/>. Returns <see langword="false"/>
|
||||
/// <see cref="ComputeContractACellArray"/> (registration) and
|
||||
/// <see cref="PublishRetailProductFromExactCells"/> (movement). Returns
|
||||
/// <see langword="false"/>
|
||||
/// when no retail part array was ever supplied for this entity (every
|
||||
/// existing caller that omits the new trailing <c>partArray</c> parameter
|
||||
/// on <see cref="Register"/>/<see cref="RegisterMultiPart"/>) or when the
|
||||
|
|
@ -698,6 +692,208 @@ public sealed class ShadowObjectRegistry
|
|||
? route
|
||||
: RetailCellArrayRoute.None;
|
||||
|
||||
/// <summary>
|
||||
/// Hard bound on an attach chain's depth — mirrors the render-side
|
||||
/// parent-chain walk this API replaces (Campaign OVERHAUL S2 chunk 4).
|
||||
/// A corrupted or cyclic caller cannot stall a registration.
|
||||
/// </summary>
|
||||
private const int MaxAttachChainDepth = 64;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign OVERHAUL S2 chunk 4: retail's <c>CPhysicsObj::add_shadows_to_cells</c>
|
||||
/// (0x00514ae0) child-inheritance recursion — "after the root,
|
||||
/// add_shadows_to_cells recursively passes the same CELLARRAY to every
|
||||
/// object in <c>children</c>, in child-list order"
|
||||
/// (<c>oh1-construction-landscape-contract.md</c> Contract B).
|
||||
/// acdream's attached projections (equipped weapons/shields/ammunition)
|
||||
/// own no independent collision shapes, so this publishes PART ENTRIES
|
||||
/// into every cell of <paramref name="rootEntityId"/>'s CURRENT retail
|
||||
/// CELLARRAY — never a collision row. <paramref name="rootEntityId"/> is
|
||||
/// the entity <paramref name="childEntityId"/> attaches TO; it may
|
||||
/// itself already be an attached child, in which case the ULTIMATE root
|
||||
/// is resolved by walking the existing attach chain (nested attachment).
|
||||
/// Re-published automatically whenever the root's array changes
|
||||
/// (registration, move, staged apply, <see cref="ReplaceMultiPartPayload"/>)
|
||||
/// via <see cref="RepublishAttachedChildren"/>. A cycle or a chain deeper
|
||||
/// than <see cref="MaxAttachChainDepth"/> is rejected — returns
|
||||
/// <see langword="false"/> without mutating any state. Re-attaching an
|
||||
/// already-attached child (a reparent) detaches it from its previous
|
||||
/// parent first.
|
||||
/// </summary>
|
||||
public bool AttachChild(
|
||||
uint childEntityId,
|
||||
uint rootEntityId,
|
||||
IReadOnlyList<ShadowShape> childPartArray)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(childPartArray);
|
||||
if (!TryValidateAttach(childEntityId, rootEntityId))
|
||||
return false;
|
||||
|
||||
if (_childParent.ContainsKey(childEntityId))
|
||||
DetachChildCore(childEntityId, removeFromParentList: true);
|
||||
|
||||
_childParent[childEntityId] = rootEntityId;
|
||||
if (!_parentChildren.TryGetValue(rootEntityId, out List<uint>? siblings))
|
||||
{
|
||||
siblings = new List<uint>();
|
||||
_parentChildren[rootEntityId] = siblings;
|
||||
}
|
||||
siblings.Add(childEntityId);
|
||||
_childPartArrays[childEntityId] = childPartArray;
|
||||
|
||||
PublishChildEntries(childEntityId);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign OVERHAUL S2 chunk 4: the inverse of <see cref="AttachChild"/>
|
||||
/// — retail's <c>remove_shadows_from_cells</c> (0x00511230) symmetry for
|
||||
/// one attached child. Also detaches every descendant of
|
||||
/// <paramref name="childEntityId"/> (a nested child of a child), matching
|
||||
/// retail's recursion through <c>children</c> on removal too. No-op
|
||||
/// (returns <see langword="false"/>) when <paramref name="childEntityId"/>
|
||||
/// is not currently attached.
|
||||
/// </summary>
|
||||
public bool DetachChild(uint childEntityId) =>
|
||||
DetachChildCore(childEntityId, removeFromParentList: true);
|
||||
|
||||
private bool DetachChildCore(uint childEntityId, bool removeFromParentList)
|
||||
{
|
||||
if (!_childParent.TryGetValue(childEntityId, out uint parentId))
|
||||
return false;
|
||||
|
||||
if (_parentChildren.TryGetValue(childEntityId, out List<uint>? grandchildren)
|
||||
&& grandchildren.Count > 0)
|
||||
{
|
||||
uint[] toDetach = grandchildren.ToArray();
|
||||
for (int i = 0; i < toDetach.Length; i++)
|
||||
DetachChildCore(toDetach[i], removeFromParentList: false);
|
||||
_parentChildren.Remove(childEntityId);
|
||||
}
|
||||
|
||||
if (_retailCellArrays.TryGetValue(childEntityId, out List<uint>? cells))
|
||||
{
|
||||
RemoveRetailPartEntriesFromCells(childEntityId, cells);
|
||||
_retailCellArrays.Remove(childEntityId);
|
||||
}
|
||||
_childPartArrays.Remove(childEntityId);
|
||||
_childParent.Remove(childEntityId);
|
||||
|
||||
if (removeFromParentList
|
||||
&& _parentChildren.TryGetValue(parentId, out List<uint>? siblings))
|
||||
{
|
||||
siblings.Remove(childEntityId);
|
||||
if (siblings.Count == 0)
|
||||
_parentChildren.Remove(parentId);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates an <see cref="AttachChild"/> call before any state
|
||||
/// mutation: rejects a direct self-attach and any attach that would form
|
||||
/// a cycle (<paramref name="childEntityId"/> already an ancestor of
|
||||
/// <paramref name="parentId"/> through the existing chain), and bounds
|
||||
/// the walk at <see cref="MaxAttachChainDepth"/>.
|
||||
/// </summary>
|
||||
private bool TryValidateAttach(uint childEntityId, uint parentId)
|
||||
{
|
||||
if (childEntityId == parentId)
|
||||
return false;
|
||||
|
||||
uint current = parentId;
|
||||
for (int depth = 0; depth < MaxAttachChainDepth; depth++)
|
||||
{
|
||||
if (!_childParent.TryGetValue(current, out uint next))
|
||||
return true; // reached the current chain's root — no cycle
|
||||
if (next == childEntityId)
|
||||
return false; // would form a cycle
|
||||
current = next;
|
||||
}
|
||||
return false; // chain too deep — defensive
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Walks <see cref="_childParent"/> from <paramref name="entityId"/> up
|
||||
/// to its ultimate root (an id with no recorded parent), bounded at
|
||||
/// <see cref="MaxAttachChainDepth"/>. Safe to call post-attach because
|
||||
/// <see cref="TryValidateAttach"/> already proved the chain acyclic.
|
||||
/// </summary>
|
||||
private uint ResolveAttachRoot(uint entityId)
|
||||
{
|
||||
uint current = entityId;
|
||||
for (int depth = 0; depth < MaxAttachChainDepth; depth++)
|
||||
{
|
||||
if (!_childParent.TryGetValue(current, out uint parent))
|
||||
return current;
|
||||
current = parent;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// (Re)publishes <paramref name="childEntityId"/>'s retail part entries
|
||||
/// from its resolved root's CURRENT retail cell array — never an
|
||||
/// independent flood. Clears the child's own previously published
|
||||
/// entries first. When the root has no published array (not yet
|
||||
/// registered, or its own array collapsed to empty), the child's
|
||||
/// entries are cleared too and nothing is republished — the same
|
||||
/// keep-when-empty symmetry <see cref="PublishRetailCellArray"/> applies
|
||||
/// to the root itself.
|
||||
/// </summary>
|
||||
private void PublishChildEntries(uint childEntityId)
|
||||
{
|
||||
if (_retailCellArrays.TryGetValue(childEntityId, out List<uint>? previousCells))
|
||||
{
|
||||
RemoveRetailPartEntriesFromCells(childEntityId, previousCells);
|
||||
_retailCellArrays.Remove(childEntityId);
|
||||
}
|
||||
if (!_childPartArrays.TryGetValue(childEntityId, out IReadOnlyList<ShadowShape>? partArray))
|
||||
return;
|
||||
|
||||
uint root = ResolveAttachRoot(childEntityId);
|
||||
if (root == childEntityId
|
||||
|| !_retailCellArrays.TryGetValue(root, out List<uint>? rootCells)
|
||||
|| rootCells.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Share the root's own list reference — PublishRetailCellArray never
|
||||
// mutates a published List<uint> in place, always replaces it
|
||||
// wholesale, so this stays valid until the NEXT publish (which
|
||||
// republishes this child too, see RepublishAttachedChildren).
|
||||
_retailCellArrays[childEntityId] = rootCells;
|
||||
PublishRetailPartEntries(childEntityId, rootCells, partArray);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-publishes every attached child of <paramref name="rootId"/> — DIRECT
|
||||
/// children first (in child-list/attach order), each recursively followed
|
||||
/// by its OWN attached children, matching retail's
|
||||
/// <c>add_shadows_to_cells</c> depth-first recursion through
|
||||
/// <c>children</c> exactly. Called from every place a root's retail cell
|
||||
/// array is (re)published: <see cref="PublishRetailCellArray"/> (covers
|
||||
/// registration and, via <see cref="PublishRetailProductFromExactCells"/>,
|
||||
/// movement), <see cref="TryApplySetPosition"/> (staged apply), and
|
||||
/// <see cref="ReplaceMultiPartPayload"/>. A no-op leaf call when
|
||||
/// <paramref name="rootId"/> has no attached children.
|
||||
/// </summary>
|
||||
private void RepublishAttachedChildren(uint rootId)
|
||||
{
|
||||
if (!_parentChildren.TryGetValue(rootId, out List<uint>? children)
|
||||
|| children.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
for (int i = 0; i < children.Count; i++)
|
||||
{
|
||||
uint childId = children[i];
|
||||
PublishChildEntries(childId);
|
||||
RepublishAttachedChildren(childId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Register a single-shape entity. <paramref name="seedCellId"/> is the
|
||||
/// entity's <c>m_position.objcell_id</c> — the flood seed. Pass 0 to
|
||||
|
|
@ -1029,6 +1225,13 @@ public sealed class ShadowObjectRegistry
|
|||
{
|
||||
RemoveRetailPartEntriesFromCells(entityId, retailCells);
|
||||
PublishRetailPartEntries(entityId, retailCells, partArray);
|
||||
// Campaign OVERHAUL S2 chunk 4: the root's own cell SET is
|
||||
// unchanged here (SetPart never re-floods), but item B's
|
||||
// contract lists this call among the root-array-touching
|
||||
// sites a child must stay consistent with — cheap and
|
||||
// harmless since the child's cells never actually change
|
||||
// when the root's do not.
|
||||
RepublishAttachedChildren(entityId);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1675,6 +1878,13 @@ public sealed class ShadowObjectRegistry
|
|||
_ownerVersions[prepared.EntityId] = prepared.FinalOwnerVersion;
|
||||
_lastAppliedSetPositionCommitId = prepared.CommitId;
|
||||
_pendingSetPositionDispatches.Add(prepared.CommitId);
|
||||
// Campaign OVERHAUL S2 chunk 4: the staging registry that computed
|
||||
// `prepared` carries none of THIS (live) registry's attach
|
||||
// relationships — RepublishAttachedChildren was a no-op there. Now
|
||||
// that the root's new retail cell array is live, propagate it to
|
||||
// every attached child (item B: "staged apply" is one of the
|
||||
// triggers).
|
||||
RepublishAttachedChildren(prepared.EntityId);
|
||||
receipt = new SetPositionShadowCommitReceipt(
|
||||
prepared.CommitId,
|
||||
prepared.EntityId,
|
||||
|
|
@ -2060,15 +2270,18 @@ public sealed class ShadowObjectRegistry
|
|||
return;
|
||||
}
|
||||
|
||||
// Campaign OVERHAUL S2 chunk 4: retail's keep-when-empty gate
|
||||
// (pc:283540) reached its terminal case — no retained collision
|
||||
// cells at all to republish from — so BOTH products stay exactly as
|
||||
// they were. No independent retail-array recompute here any more;
|
||||
// that was the "two floods disagreeing over the same input"
|
||||
// duplication chunk 3's evidence (§6) flagged and chunk 4 removes.
|
||||
_entityReg[entityId] = registration with
|
||||
{
|
||||
SeedCellId = seedCellId,
|
||||
EntityWorldPos = worldPosition,
|
||||
EntityWorldRot = worldRotation,
|
||||
};
|
||||
RecomputeRetailCellArrayIfPresent(
|
||||
entityId, seedCellId, worldPosition, worldRotation,
|
||||
registration.State, registration.IsStatic);
|
||||
BumpOwnerVersion(entityId);
|
||||
}
|
||||
|
||||
|
|
@ -2171,9 +2384,14 @@ public sealed class ShadowObjectRegistry
|
|||
if (withdrawn.Count == 0)
|
||||
_withdrawnPrefixesByOwner.Remove(entityId);
|
||||
}
|
||||
RecomputeRetailCellArrayIfPresent(
|
||||
entityId, seedCellId, worldPosition, worldRotation,
|
||||
registration.State, registration.IsStatic);
|
||||
// Campaign OVERHAUL S2 chunk 4: one array, two products. The retail
|
||||
// render product is published from the SAME exact cell list collision
|
||||
// just used (`exactCells`) — not a second, independently-flooded
|
||||
// array — mirroring retail's SetPositionInternal
|
||||
// add_shadows_to_cells(this, &arg2->cell_array) call (pc:283537),
|
||||
// which consumes the transition's own array for both products at
|
||||
// once.
|
||||
PublishRetailProductFromExactCells(entityId, exactCells);
|
||||
BumpOwnerVersion(entityId);
|
||||
}
|
||||
|
||||
|
|
@ -2470,9 +2688,30 @@ public sealed class ShadowObjectRegistry
|
|||
BumpOwnerVersion(entityId);
|
||||
}
|
||||
|
||||
/// <summary>Remove an entity from all cells it was registered in.</summary>
|
||||
/// <summary>
|
||||
/// Remove an entity from all cells it was registered in. Campaign
|
||||
/// OVERHAUL S2 chunk 4: mirrors retail's <c>remove_shadows_from_cells</c>
|
||||
/// (0x00511230) full symmetry — a genuine teardown (as opposed to the
|
||||
/// internal "clear then re-register" idiom <see cref="Register"/>/
|
||||
/// <see cref="RegisterMultiPart"/> use ahead of their own flood, which
|
||||
/// must NOT detach children) cascades through every attached child
|
||||
/// (Contract B: "remove_shadows_from_cells... recurses through
|
||||
/// children"), and detaches this entity itself if it was ITSELF an
|
||||
/// attached child.
|
||||
/// </summary>
|
||||
public void Deregister(uint entityId)
|
||||
=> DeregisterCore(entityId, publishMutation: true);
|
||||
{
|
||||
if (_parentChildren.TryGetValue(entityId, out List<uint>? children)
|
||||
&& children.Count > 0)
|
||||
{
|
||||
uint[] toDetach = children.ToArray();
|
||||
for (int i = 0; i < toDetach.Length; i++)
|
||||
DetachChildCore(toDetach[i], removeFromParentList: false);
|
||||
_parentChildren.Remove(entityId);
|
||||
}
|
||||
DetachChildCore(entityId, removeFromParentList: true);
|
||||
DeregisterCore(entityId, publishMutation: true);
|
||||
}
|
||||
|
||||
private void DeregisterCore(uint entityId, bool publishMutation)
|
||||
{
|
||||
|
|
@ -3147,8 +3386,8 @@ public sealed class ShadowObjectRegistry
|
|||
}
|
||||
|
||||
// Campaign OVERHAUL S2 chunk 3: seed the retail render product too —
|
||||
// without this, a staging registry's own internal Contract-A
|
||||
// recompute (RecomputeRetailCellArrayIfPresent, reached through
|
||||
// without this, a staging registry's own internal move-path publish
|
||||
// (PublishRetailProductFromExactCells, reached through
|
||||
// CommitSetPosition) finds nothing retained and silently no-ops,
|
||||
// dropping the retail product for every entity that moves through
|
||||
// the staged TryPrepareSetPosition/TryApplySetPosition pipeline.
|
||||
|
|
@ -3428,6 +3667,17 @@ public sealed class ShadowObjectRegistry
|
|||
_prefixScratch.Clear();
|
||||
_removedPrefixScratch.Clear();
|
||||
_pendingSetPositionDispatches.Clear();
|
||||
// The retail render product (chunk 1/3) and the attach-chain state
|
||||
// (chunk 4) were missing from this reset — a stale entry would
|
||||
// otherwise survive a terminal Clear() into whatever the SAME
|
||||
// registry instance is used for next.
|
||||
_entityRetailPartArrays.Clear();
|
||||
_retailCellArrays.Clear();
|
||||
_retailCellArrayRoutes.Clear();
|
||||
_retailPartEntriesByCell.Clear();
|
||||
_childParent.Clear();
|
||||
_parentChildren.Clear();
|
||||
_childPartArrays.Clear();
|
||||
_fallback = null;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue