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

@ -520,13 +520,6 @@ public sealed class ArchRenderSceneTests
LightCandidate: 1,
Dirty: 6),
query.IndexCounts);
Assert.Equal(1, query.GetCellStaticCount(indoorCell));
Assert.Equal(1, query.GetCellDynamicCount(indoorCell));
var cellRecords = new RenderProjectionRecord[1];
Assert.Equal(1, query.CopyCellStaticsTo(indoorCell, cellRecords));
Assert.Equal(indoorStatic.Id, cellRecords[0].Id);
Assert.Equal(1, query.CopyCellDynamicsTo(indoorCell, cellRecords));
Assert.Equal(cellDynamic.Id, cellRecords[0].Id);
Assert.True(scene.Memory.EstimatedIndexBytes > 0);
}
@ -580,7 +573,6 @@ public sealed class ArchRenderSceneTests
Assert.Equal(1, updated.IndexCounts.Translucent);
Assert.Equal(1, updated.IndexCounts.LightCandidate);
Assert.Equal(1, updated.IndexCounts.Dirty);
Assert.Equal(1, updated.GetCellDynamicCount(indoorCell));
RenderProjectionRecord replacement = rebucketed with
{
@ -592,7 +584,6 @@ public sealed class ArchRenderSceneTests
RenderSceneQuery replaced = scene.OpenQuery();
Assert.Equal(0, replaced.IndexCounts.Dynamic);
Assert.Equal(1, replaced.IndexCounts.IndoorCellStatic);
Assert.Equal(1, replaced.GetCellStaticCount(indoorCell));
scene.Apply(
[
@ -1085,6 +1076,92 @@ public sealed class ArchRenderSceneTests
Assert.Equal(changed.LatestRevision, query.DirectionalShadowTransformRevision);
}
// Campaign OVERHAUL S2 review fix round (F5/F6): the LocalEntityId index.
private static RenderProjectionRecord WithLocalEntityId(
RenderProjectionRecord record,
uint localEntityId) =>
record with
{
Source = new RenderSourceMetadata(
LocalEntityId: localEntityId,
ServerGuid: 0,
SourceId: 1,
ParentCellId: record.Source.ParentCellId,
EffectCellId: 0,
BuildingShellAnchorCellId: 0,
TransformFingerprint: new RenderSceneHash128(1, 1),
GeometryFingerprint: new RenderSceneHash128(2, 2),
AppearanceFingerprint: new RenderSceneHash128(3, 3)),
};
[Fact]
public void TryGetByLocalEntityId_IgnoresEnvCellShellRecords()
{
// An EnvCell shell carries its CELL id in the entity-id slot; a
// dungeon cell id such as 0x8A020100 is numerically a procedural
// scenery entity id. The shell must never shadow the real entity.
const uint aliasedId = 0x8A020100u;
RenderSceneGeneration generation = Generation(31);
using var scene = new ArchRenderScene(generation);
RenderProjectionRecord shell = WithLocalEntityId(
Record(310, 1, RenderProjectionClass.IndoorCellStatic), aliasedId);
RenderProjectionRecord scenery = WithLocalEntityId(
Record(311, 1, RenderProjectionClass.OutdoorStatic), aliasedId);
scene.Apply(
[
RenderProjectionDelta.Register(generation, 1, shell),
RenderProjectionDelta.Register(generation, 2, scenery),
]);
RenderSceneQuery query = scene.OpenQuery();
Assert.True(query.TryGetByLocalEntityId(aliasedId, out RenderProjectionRecord found));
Assert.Equal(scenery.Id, found.Id);
// Unregistering the shell never disturbs the entity's mapping.
scene.Apply(
[
RenderProjectionDelta.Unregister(
generation,
3,
shell.Id,
shell.OwnerIncarnation),
]);
query = scene.OpenQuery();
Assert.True(query.TryGetByLocalEntityId(aliasedId, out found));
Assert.Equal(scenery.Id, found.Id);
}
[Fact]
public void Update_ThatRebindsTheLocalEntityId_MovesTheIndex()
{
RenderSceneGeneration generation = Generation(32);
using var scene = new ArchRenderScene(generation);
RenderProjectionRecord original = WithLocalEntityId(
Record(320, 1, RenderProjectionClass.LiveDynamicRoot), 19);
scene.Apply([RenderProjectionDelta.Register(generation, 1, original)]);
Assert.True(scene.OpenQuery().TryGetByLocalEntityId(19, out _));
// Same class, cell, flags, mesh count and sort key — only the id
// changes; the index must follow it (IndexMembershipEquals compares
// the id since the S2 review fix).
RenderProjectionRecord rebound = WithLocalEntityId(original, 20);
scene.Apply(
[
RenderProjectionDelta.Update(
RenderProjectionDeltaKind.UpdateFlags,
generation,
2,
rebound),
]);
RenderSceneQuery query = scene.OpenQuery();
Assert.False(query.TryGetByLocalEntityId(19, out _));
Assert.True(query.TryGetByLocalEntityId(20, out RenderProjectionRecord found));
Assert.Equal(original.Id, found.Id);
}
private static RenderProjectionRecord Record(
ulong id,
ulong incarnation,