feat(render): Campaign OVERHAUL S2 chunk 5 + closeout — registry is the only render membership owner

Chunk 5 (consumer cutover): WalkProductionWorldData's per-cell views are
borrowed from ShadowObjectRegistry.GetRetailPartEntriesInCell and resolved
through RenderSceneQuery.TryGetByLocalEntityId; every render-side sweep,
bucket, parent-cell and root-position fallback is deleted (AD-116 for the
one-frame registry→scene window, counted in UnregisteredRenderMembershipCount).
A live entity with visual parts but no collision geometry registers
render-only (LiveEntityCollisionBuilder computes the part array before the
empty-shapes gate).

Closeout fixes found while landing it:
- RefloodOwnerForLandblock forwards the retained part array — a reflood is
  retail's recalc_cross_cells over the SAME CPartArray; without it every owner
  touched by a landblock replacement commit lost its render membership.
- Non-colliding DAT statics register render-only from BOTH publishers
  (LandblockPhysicsPublisher.PublishStaticEntity,
  LandblockPhysicsContentBuilder.RegisterRenderOnlyStatic). The G2 self-gate
  pixel diff caught them vanishing (Facility Hub wall panels): retail floods
  every object regardless of collision (CEnvCell::init_static_objects
  0x0052c350, add_shadows_to_cells 0x00514ae0).
- S2 dual review fix batch (arch + retail lens, lead-verified):
  Suspend clears the retail product (remove_shadows_from_cells 0x00511230 is
  one transaction); AttachChild/DetachChild advance the mutation revision so
  a prepared SetPosition cannot clobber a child's rows; an attached child
  never floods on its own re-registration; RemoveLandblock and the non-rooted
  RetireOwnerFromLandblock prune retail rows (render-only statics end with
  their landblock); a render-only owner's no-cell-array commit republishes at
  its destination cell (AD-117); an empty non-null part array is treated as
  null; per-move closures/LINQ replaced by index loops; EnvCell shells stay
  out of the scene's LocalEntityId index (payload-less records); the index
  predicate compares the id; the dead per-cell scene indices are deleted.

Register: AD-116 (chunk 5), AD-117 (four residual Contract A/B readings).
Evidence: s2-membership-ownership-map.md §8 (chunk 5) and §9 (closeout).

Gates (Release): Core 4,984/4,984; Content 214/214; Runtime 1,884/1,884;
App hermetic lane 6,760/6,760; App InstalledDat lane 217 pass / 1 skip /
2 pre-existing #383 layout-fixture failures; App Windows lane 1/1.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-09-03 01:01:47 +02:00
parent f30039d3d9
commit c94a1a407e
20 changed files with 2141 additions and 1026 deletions

View file

@ -506,6 +506,20 @@ public sealed class ShadowObjectRegistry
RetailCellArrayRoute route,
IReadOnlyList<ShadowShape> partArray)
{
// Campaign OVERHAUL S2 review fix (arch F3): retail never floods an
// attached child — add_shadows_to_cells (0x00514ae0) passes the
// ROOT's CELLARRAY down through `children`. A re-registration or move
// of an entity that is currently attached (an equipped item's
// appearance update through ReplaceMultiPartPayload/RegisterMultiPart)
// only refreshes the part array it inherits the root's cells with.
if (_childParent.ContainsKey(entityId))
{
if (partArray.Count != 0)
_childPartArrays[entityId] = partArray;
_retailCellArrayRoutes[entityId] = route;
PublishChildEntries(entityId);
return;
}
if (_retailCellArrays.TryGetValue(entityId, out List<uint>? previousCells))
{
RemoveRetailPartEntriesFromCells(entityId, previousCells);
@ -604,13 +618,71 @@ public sealed class ShadowObjectRegistry
cellIds[i],
out List<RetailPartEntry>? entries))
{
entries.RemoveAll(e => e.EntityId == entityId);
RemoveOwnerPartRows(entries, entityId);
if (entries.Count == 0)
_retailPartEntriesByCell.Remove(cellIds[i]);
}
}
}
/// <summary>The <see cref="RemoveOwnerRows"/> twin for the retail part
/// rows: a reverse index loop, no closure — this runs per cell on every
/// accepted move (arch review F7).</summary>
private static void RemoveOwnerPartRows(
List<RetailPartEntry> entries,
uint entityId)
{
for (int index = entries.Count - 1; index >= 0; index--)
{
if (entries[index].EntityId == entityId)
entries.RemoveAt(index);
}
}
private static ShadowEntry[] CollectOwnerRows(
List<ShadowEntry> entries,
uint entityId)
{
int count = 0;
for (int index = 0; index < entries.Count; index++)
{
if (entries[index].EntityId == entityId)
count++;
}
if (count == 0)
return Array.Empty<ShadowEntry>();
var rows = new ShadowEntry[count];
int written = 0;
for (int index = 0; index < entries.Count; index++)
{
if (entries[index].EntityId == entityId)
rows[written++] = entries[index];
}
return rows;
}
private static RetailPartEntry[] CollectOwnerPartRows(
List<RetailPartEntry> entries,
uint entityId)
{
int count = 0;
for (int index = 0; index < entries.Count; index++)
{
if (entries[index].EntityId == entityId)
count++;
}
if (count == 0)
return Array.Empty<RetailPartEntry>();
var rows = new RetailPartEntry[count];
int written = 0;
for (int index = 0; index < entries.Count; index++)
{
if (entries[index].EntityId == entityId)
rows[written++] = entries[index];
}
return rows;
}
/// <summary>
/// Publishes retail's <c>CPartArray::AddPartsShadow</c> (0x00517e40) rows
/// for one entity: for every cell in <paramref name="orderedCells"/>, in
@ -742,6 +814,12 @@ public sealed class ShadowObjectRegistry
_childPartArrays[childEntityId] = childPartArray;
PublishChildEntries(childEntityId);
// Campaign OVERHAUL S2 review fix (arch F2): an attach mutates shared
// per-cell rows, so it must invalidate any prepared SetPosition commit
// whose captured cell lists predate it (IsPreparedSetPositionCurrent
// keys on the mutation revision) — otherwise that commit's wholesale
// list install silently drops the child's rows.
AdvanceMutationRevision();
return true;
}
@ -786,6 +864,7 @@ public sealed class ShadowObjectRegistry
if (siblings.Count == 0)
_parentChildren.Remove(parentId);
}
AdvanceMutationRevision(); // see AttachChild (arch F2)
return true;
}
@ -1041,7 +1120,32 @@ public sealed class ShadowObjectRegistry
bool publishMutation = true,
IReadOnlyList<ShadowShape>? partArray = null)
{
if (shapes.Count == 0) { Deregister(entityId); return; }
if (shapes.Count == 0)
{
// Campaign OVERHAUL S2 chunk 5 (item B): a live entity whose
// collision dispatch produced NO shapes (LiveEntityCollisionBuilder.
// Build's Shapes==[]) but whose Setup still has visual parts — the
// retail short-lived spell/visual effect-object case — is NOT a
// deregistration. Contract B's render membership
// (CPartArray::AddPartsShadow) does not require a successful
// collision dispatch; only the collision-side shadow_object_list
// does. Route it to the render-only registration path instead.
// A caller with no part array either (every pre-chunk-5 test
// fixture, and any genuinely shapeless entity) keeps the exact
// prior behavior: deregister.
if (partArray is { Count: > 0 })
{
RegisterRenderOnly(
entityId, entityWorldPos, entityWorldRot, state, flags,
worldOffsetX, worldOffsetY, landblockId, seedCellId,
isStatic, publishMutation, partArray);
}
else
{
Deregister(entityId);
}
return;
}
// Flood FIRST — keep-when-empty, see Register.
uint seed = seedCellId != 0u
@ -1157,6 +1261,79 @@ public sealed class ShadowObjectRegistry
}
}
/// <summary>
/// Campaign OVERHAUL S2 chunk 5 (item B): registers a live entity that
/// has NO collision shapes at all but DOES have a visual part array — the
/// retail short-lived spell/visual effect-object case
/// (<c>LiveEntityCollisionBuilder.Build</c> returns
/// <c>Shapes = []</c>, <c>RenderParts != []</c>). Retail's
/// <c>calc_cross_cells_static</c> dispatch (Contract A) never
/// special-cases "no collision shapes": the cylsphere-vs-bbox TEST reads
/// the collision shapes (none here, so the test is always false) and the
/// flood unconditionally falls through to the bbox route over the WHOLE
/// part array (<see cref="ComputeContractACellArray"/> with an empty
/// <c>collisionShapes</c> list). The entity therefore gets an exact
/// retail CELLARRAY and <see cref="RetailPartEntry"/> rows exactly like a
/// colliding entity, but never a <see cref="ShadowEntry"/> collision row
/// anywhere — Contract B's <c>shadow_object_list</c> receives one entry
/// per CELLARRAY cell only when the object HAS a part to shadow there;
/// this object contributes to <c>shadow_part_list</c> only.
/// <para>
/// <see cref="_entityShapes"/> is retained as an EMPTY (not absent) list
/// so every existing multi-part-dispatch site
/// (<see cref="UpdatePosition"/>, <see cref="ReplacePositionRows"/>,
/// <see cref="RefloodOwnerForLandblock"/>) takes the "multi-part, zero
/// shapes" branch on a later move/reflood instead of falling back to the
/// single-shape <see cref="Register"/> path or synthesizing a bogus
/// zero-radius shadow entry.
/// </para>
/// </summary>
private void RegisterRenderOnly(
uint entityId,
Vector3 entityWorldPos,
Quaternion entityWorldRot,
uint state,
EntityCollisionFlags flags,
float worldOffsetX,
float worldOffsetY,
uint landblockId,
uint seedCellId,
bool isStatic,
bool publishMutation,
IReadOnlyList<ShadowShape> partArray)
{
// Flood FIRST — keep-when-empty, see Register.
uint seed = seedCellId != 0u
? seedCellId
: DeriveOutdoorSeed(entityWorldPos, worldOffsetX, worldOffsetY, landblockId);
if (seed == 0u) return;
(IReadOnlyList<uint> cellSet, RetailCellArrayRoute retailRoute) =
ComputeContractACellArray(
seed,
entityWorldPos,
entityWorldRot,
state,
collisionShapes: Array.Empty<ShadowShape>(),
partArray,
isStatic);
if (cellSet.Count == 0) return; // keep-when-empty (pc:283540).
DeregisterCore(entityId, publishMutation: false);
_entityShapes[entityId] = Array.Empty<ShadowShape>();
_entityReg[entityId] = new RegistrationRecord(
seed, entityWorldPos, entityWorldRot, state, flags, isStatic,
IsMultiPart: true, GfxObjId: 0u, Radius: 0f,
CollisionType: ShadowCollisionType.BSP, CylHeight: 0f, Scale: 1f);
if (publishMutation)
BumpOwnerVersion(entityId);
else
RefreshOwnerPrefixIndex(entityId);
_entityRetailPartArrays[entityId] = partArray;
PublishRetailCellArray(entityId, cellSet, retailRoute, partArray);
}
/// <summary>
/// Replaces an existing live PartArray collision payload in its current
/// shadow-cell membership. Retail <c>CPartArray::SetPart</c> changes the
@ -1183,7 +1360,11 @@ public sealed class ShadowObjectRegistry
if (!_entityReg.TryGetValue(entityId, out RegistrationRecord? prior)
|| !prior.IsMultiPart)
{
if (shapes.Count == 0)
// Campaign OVERHAUL S2 chunk 5 (item B): a not-yet-registered
// entity with no collision shapes but a real part array still
// needs registering (render-only) — RegisterMultiPart's own
// shapes.Count==0 branch now handles that dispatch.
if (shapes.Count == 0 && (partArray is null || partArray.Count == 0))
return;
RegisterMultiPart(
entityId,
@ -1217,7 +1398,10 @@ public sealed class ShadowObjectRegistry
// this replaces the retained part array WITHOUT re-flooding. It
// reuses the CURRENT retail CELLARRAY (if any) and only rewrites
// which parts occupy it, mirroring the collision payload swap below.
if (partArray is not null)
// Arch review F9: a non-null but EMPTY part array is "no part to
// shadow" — treated exactly like the null case rather than leaving a
// cell array with no drawable rows behind.
if (partArray is { Count: > 0 })
{
_entityRetailPartArrays[entityId] = partArray;
if (_retailCellArrays.TryGetValue(entityId, out List<uint>? retailCells)
@ -2270,6 +2454,37 @@ public sealed class ShadowObjectRegistry
return;
}
// Campaign OVERHAUL S2 review fix (retail F3): a RENDER-ONLY owner
// (part array, no collision shapes, hence never any retained
// collision cells) still moves in retail — add_shadows_to_cells
// (0x00514ae0) adds the CShadowObj unconditionally (pc:282856) and
// gates only AddPartsShadow on part_array != 0, so a shapeless
// visual object keeps a cell array it travels with. Its transition
// carries no sphere, so that array is its destination cell alone
// (num_cells == 1 → AddPartsShadow without clip planes). AD-117
// records the single-cell reading until a cdb trace pins the
// zero-sphere transition's exact cell list.
if (!_entityToCells.ContainsKey(entityId)
&& !_suspendedEntityCells.ContainsKey(entityId)
&& seedCellId != 0u
&& _entityRetailPartArrays.TryGetValue(
entityId,
out IReadOnlyList<ShadowShape>? renderPartArray)
&& renderPartArray.Count != 0)
{
_suspendedEntities.Remove(entityId);
_entityReg[entityId] = registration with
{
SeedCellId = seedCellId,
EntityWorldPos = worldPosition,
EntityWorldRot = worldRotation,
};
_singleCellScratch[0] = seedCellId;
PublishRetailProductFromExactCells(entityId, _singleCellScratch);
BumpOwnerVersion(entityId);
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
@ -2285,6 +2500,8 @@ public sealed class ShadowObjectRegistry
BumpOwnerVersion(entityId);
}
private readonly uint[] _singleCellScratch = new uint[1];
private void ReplacePositionRows(
uint entityId,
RegistrationRecord registration,
@ -2326,12 +2543,12 @@ public sealed class ShadowObjectRegistry
exactCells.Add(cellId);
}
if (registration.IsMultiPart
&& _entityShapes.TryGetValue(
entityId,
out IReadOnlyList<ShadowShape>? shapes))
IReadOnlyList<ShadowShape>? shapes = null;
bool isMultiPartDispatch = registration.IsMultiPart
&& _entityShapes.TryGetValue(entityId, out shapes);
if (isMultiPartDispatch)
{
foreach (ShadowShape shape in shapes)
foreach (ShadowShape shape in shapes!)
{
Vector3 partWorldPosition = worldPosition
+ Vector3.Transform(shape.LocalPosition, worldRotation);
@ -2371,7 +2588,15 @@ public sealed class ShadowObjectRegistry
AddEntryToCell(entry, exactCells[index]);
}
if (exactCells.Count == 0)
// Campaign OVERHAUL S2 chunk 5 (item B): a render-only multi-part
// entity (registration.IsMultiPart with an EMPTY retained shapes
// list — see RegisterRenderOnly) writes NO ShadowEntry rows above.
// _entityToCells must stay absent for it too, or a consumer reading
// it (GetOwnerCells, DeregisterCore's cleanup walk) would see cell
// ids that carry no actual collision row — a membership claim this
// registry never backs with a shadow_object_list entry.
bool wroteCollisionEntries = !isMultiPartDispatch || shapes!.Count != 0;
if (exactCells.Count == 0 || !wroteCollisionEntries)
_entityToCells.Remove(entityId);
else
_entityToCells[entityId] = exactCells;
@ -2414,11 +2639,26 @@ public sealed class ShadowObjectRegistry
foreach (uint cellId in cellIds)
{
if (_cells.TryGetValue(cellId, out var list))
list.RemoveAll(entry => entry.EntityId == entityId);
RemoveOwnerRows(list, entityId);
}
_entityToCells.Remove(entityId);
}
// Campaign OVERHAUL S2 review fix (arch F1 / retail F2): retail's
// remove_shadows_from_cells (0x00511230) is ONE transaction over both
// products — per shadow cell it calls CObjCell::remove_shadow_object
// AND CPartArray::RemoveParts, then recurses through children. A
// suspended object therefore has no render membership either; the
// retained part array and route survive so the un-suspending move
// (ReplacePositionRows → PublishRetailProductFromExactCells)
// republishes from the transition's cells.
if (_retailCellArrays.TryGetValue(entityId, out List<uint>? retailCells))
{
RemoveRetailPartEntriesFromCells(entityId, retailCells);
_retailCellArrays.Remove(entityId);
RepublishAttachedChildren(entityId);
}
_suspendedEntities.Add(entityId);
BumpOwnerVersion(entityId);
return true;
@ -2502,6 +2742,19 @@ public sealed class ShadowObjectRegistry
_withdrawnPrefixesByOwner.TryGetValue(
entityId,
out var withdrawnBeforeReflood);
// Campaign OVERHAUL S2 chunk 5 closeout: a reflood is retail's
// recalc_cross_cells over the SAME CPartArray — the render product
// (retail cell array + AddPartsShadow rows) is recomputed from the
// retained part array exactly like the movement path above does.
// Without this the walk (which reads ONLY GetRetailPartEntriesInCell
// since chunk 5) lost every reflooded owner: landblock replacement
// commits (PhysicsEngine.ApplyCommittedOwnerReplacement) and the
// Content builder's post-publication reflood both come through here.
_entityRetailPartArrays.TryGetValue(
entityId,
out IReadOnlyList<ShadowShape>? retainedPartArray);
if (reg.IsMultiPart
&& _entityShapes.TryGetValue(entityId, out var shapes))
{
@ -2517,7 +2770,8 @@ public sealed class ShadowObjectRegistry
lbPrefix,
reg.SeedCellId,
reg.IsStatic,
publishMutation: false);
publishMutation: false,
partArray: retainedPartArray);
}
else
{
@ -2537,7 +2791,8 @@ public sealed class ShadowObjectRegistry
reg.Flags,
reg.SeedCellId,
reg.IsStatic,
publishMutation: false);
publishMutation: false,
partArray: retainedPartArray);
}
// Register is also the authoritative movement/replacement API and
@ -2870,10 +3125,69 @@ public sealed class ShadowObjectRegistry
_withdrawnPrefixesByOwner.Remove(eid);
}
}
// Campaign OVERHAUL S2 review fix (arch F4 / retail F4): the retail
// render product ends with the landblock too — remove_shadows_from_
// cells (0x00511230) never removes the CShadowObj without the
// AddPartsShadow rows. Every part row in the prefix's cells goes;
// each owner's cell array loses those cells (a dynamic owner keeps
// its part array for the reload reflood, a static owner ends here —
// including a render-only static, which has no collision cells and
// so was invisible to the loops above).
RemoveRetailProductForPrefix(lbPrefix, touchedOwners);
foreach (uint entityId in touchedOwners)
BumpOwnerVersion(entityId);
}
private readonly List<uint> _prefixRemovalScratch = new();
private void RemoveRetailProductForPrefix(
uint lbPrefix,
HashSet<uint> touchedOwners)
{
_prefixRemovalScratch.Clear();
foreach (uint cellId in _retailPartEntriesByCell.Keys)
{
if ((cellId & 0xFFFF0000u) == lbPrefix)
_prefixRemovalScratch.Add(cellId);
}
for (int i = 0; i < _prefixRemovalScratch.Count; i++)
_retailPartEntriesByCell.Remove(_prefixRemovalScratch[i]);
_prefixRemovalScratch.Clear();
foreach (var (ownerId, cells) in _retailCellArrays)
{
for (int i = cells.Count - 1; i >= 0; i--)
{
if ((cells[i] & 0xFFFF0000u) == lbPrefix)
{
cells.RemoveAt(i);
touchedOwners.Add(ownerId);
}
}
if (cells.Count == 0)
_prefixRemovalScratch.Add(ownerId);
}
for (int i = 0; i < _prefixRemovalScratch.Count; i++)
{
uint ownerId = _prefixRemovalScratch[i];
_retailCellArrays.Remove(ownerId);
bool endsWithLandblock =
!_entityReg.TryGetValue(ownerId, out RegistrationRecord? registration)
|| registration.IsStatic;
if (!endsWithLandblock)
continue;
_retailCellArrayRoutes.Remove(ownerId);
_entityRetailPartArrays.Remove(ownerId);
// A render-only static never had collision cells, so the static
// retirement loop above could not reach its registration.
_entityShapes.Remove(ownerId);
_entityReg.Remove(ownerId);
_suspendedEntities.Remove(ownerId);
_suspendedEntityCells.Remove(ownerId);
_withdrawnPrefixesByOwner.Remove(ownerId);
}
}
/// <summary>
/// Retires one logical owner's rows from a streamed-out prefix. This is
/// the owner-granular form used by the collision-generation retirement
@ -2895,10 +3209,39 @@ public sealed class ShadowObjectRegistry
AdvanceMutationRevision();
return;
}
if (!_entityToCells.TryGetValue(entityId, out List<uint>? cells))
return;
bool touched = false;
// Campaign OVERHAUL S2 review fix (arch F4 / retail F4): the owner's
// retail part rows leave the retired prefix's cells together with
// its collision rows (remove_shadows_from_cells 0x00511230 removes
// both per cell); the part array stays for the reload reflood.
if (_retailCellArrays.TryGetValue(entityId, out List<uint>? retailCells))
{
for (int index = retailCells.Count - 1; index >= 0; index--)
{
uint cellId = retailCells[index];
if ((cellId & 0xFFFF0000u) != prefix)
continue;
touched = true;
retailCells.RemoveAt(index);
if (_retailPartEntriesByCell.TryGetValue(
cellId,
out List<RetailPartEntry>? partRows))
{
RemoveOwnerPartRows(partRows, entityId);
if (partRows.Count == 0)
_retailPartEntriesByCell.Remove(cellId);
}
}
if (retailCells.Count == 0)
_retailCellArrays.Remove(entityId);
}
if (!_entityToCells.TryGetValue(entityId, out List<uint>? cells))
{
if (touched)
BumpOwnerVersion(entityId);
return;
}
for (int index = cells.Count - 1; index >= 0; index--)
{
uint cellId = cells[index];
@ -3311,8 +3654,7 @@ public sealed class ShadowObjectRegistry
{
rows.Add(new PreparedShadowCellRows(
cellId,
entries.Where(entry => entry.EntityId == entityId)
.ToArray()));
CollectOwnerRows(entries, entityId)));
}
}
}
@ -3341,8 +3683,7 @@ public sealed class ShadowObjectRegistry
{
retailRows.Add(new PreparedShadowRetailPartRows(
cellId,
entries.Where(entry => entry.EntityId == entityId)
.ToArray()));
CollectOwnerPartRows(entries, entityId)));
}
}
}