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

@ -155,6 +155,78 @@ public sealed class LiveEntityCollisionBuilderTests
Assert.Equal(part, shape.GfxObjId);
}
/// <summary>
/// Campaign OVERHAUL S2 chunk 5 (item B): a short-lived spell/visual
/// effect object's Setup can carry a visual part with no physics BSP and
/// no CylSpheres/Spheres — retail's <c>FindObjCollisions</c> synthesizes
/// no COLLISION shape for such a part (this file's own
/// <see cref="ShapelessSetupWithRadius_ProducesNoRegistration"/> remark),
/// but the part is still a real visual part
/// <c>CPartArray::AddPartsShadow</c> must register for render (Contract
/// B). <see cref="LiveEntityCollisionBuilder.Build"/> must not collapse
/// this case to "no registration at all" just because the COLLISION
/// dispatch came up empty — it must still return a registration whose
/// <c>RenderParts</c> carries the part, and
/// <see cref="LiveEntityCollisionBuilder.Register"/> (the exact call
/// <c>DatLiveEntityProjectionMaterializer</c> makes) must register it
/// render-only: a real retail CELLARRAY with per-cell
/// <c>RetailPartEntry</c> rows, but NO collision row anywhere.
/// </summary>
[Fact]
public void Build_PartWithNoCollisionButResolvableVisualBounds_RegistersRenderOnly()
{
const uint part = 0x0100BEEFu;
var setup = new Setup();
setup.Parts.Add(part);
WorldSession.EntitySpawn spawn = Spawn(scale: 1f);
var record = LiveEntityTestFixture.CreateExactProjectionRecord(spawn);
WorldEntity entity = Entity();
record.WorldEntity = entity;
var builder = new LiveEntityCollisionBuilder(
physicsBspBounds: _ => null,
PoseResolver(),
getGfxObj: _ => null,
getVisualBounds: id => id == part
? new GfxObjVisualBounds
{
Min = new Vector3(-1f, -1f, -1f),
Max = new Vector3(1f, 1f, 1f),
Center = Vector3.Zero,
Radius = 1.5f,
HalfExtents = new Vector3(1f, 1f, 1f),
}
: null);
LiveEntityCollisionRegistration registration = Assert.IsType<LiveEntityCollisionRegistration>(
builder.Build(
entity,
setup,
[part],
spawn,
record.ServerGuid,
record.Generation,
record.WorldEntity!,
record.FinalPhysicsState,
Vector3.Zero));
Assert.Empty(registration.Shapes);
ShadowShape renderPart = Assert.Single(registration.RenderParts);
Assert.Equal(part, renderPart.GfxObjId);
var registry = new ShadowObjectRegistry();
LiveEntityCollisionBuilder.Register(registry, registration);
Assert.True(
registry.TryGetRetailCellArray(entity.Id, out IReadOnlyList<uint> cells));
Assert.NotEmpty(cells);
Assert.Empty(registry.GetOwnerCells(entity.Id));
var entries = registry.GetRetailPartEntriesInCell(cells[0])
.Where(e => e.EntityId == entity.Id)
.ToList();
Assert.Single(entries);
Assert.Equal(part, entries[0].GfxObjId);
}
/// <summary>
/// AP-152. Every other fixture in this file is primitive-only or BSP-only,
/// so nothing at the App layer used to exercise the CylSphere+BSP

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,

View file

@ -103,7 +103,6 @@ public sealed class DirectionalShadowCasterFrameTests
Assert.Equal(
[RenderSceneIndex.OutdoorStatic, RenderSceneIndex.OutdoorDynamic],
source.CopiedIndices);
Assert.Equal(0, source.CellQueries);
Assert.Equal(2, frame.Stats.IndexCopies);
}
@ -635,7 +634,6 @@ public sealed class DirectionalShadowCasterFrameTests
public int IndexCountReads { get; private set; }
public int IndexCopies { get; private set; }
public int CellQueries { get; private set; }
public int ProjectionReads { get; private set; }
public int BatchedProjectionCopies { get; private set; }
public ulong TopologyRevision { get; private set; } = 1;
@ -731,6 +729,32 @@ public sealed class DirectionalShadowCasterFrameTests
return false;
}
public bool TryGetByLocalEntityId(
RenderSceneGeneration generation,
uint localEntityId,
out RenderProjectionRecord record)
{
for (int index = 0; index < _statics.Length; index++)
{
if (_statics[index].Source.LocalEntityId == localEntityId)
{
record = _statics[index];
return true;
}
}
for (int index = 0; index < _dynamics.Length; index++)
{
if (_dynamics[index].Source.LocalEntityId == localEntityId)
{
record = _dynamics[index];
return true;
}
}
record = default;
return false;
}
public int CopyById(
RenderSceneGeneration generation,
ReadOnlySpan<RenderProjectionId> ids,
@ -791,25 +815,6 @@ public sealed class DirectionalShadowCasterFrameTests
return values.Length;
}
public int GetCellCount(
RenderSceneGeneration generation,
uint fullCellId,
bool dynamic)
{
CellQueries++;
throw new InvalidOperationException("Directional shadows do not query PView cells.");
}
public int CopyCellTo(
RenderSceneGeneration generation,
uint fullCellId,
bool dynamic,
Span<RenderProjectionRecord> destination)
{
CellQueries++;
throw new InvalidOperationException("Directional shadows do not query PView cells.");
}
public void ReplaceStatics(
RenderProjectionRecord[] values,

View file

@ -316,7 +316,7 @@ public class Issue177StairDescentCameraFloodTests
Assert.Contains(
worldData.GetCellStatics(FacilityHub | 0x015Eu).Records,
record => record.Id == projectionId);
Assert.Equal(0, worldData.UnregisteredStaticRenderFallbackCount);
Assert.Equal(0, worldData.UnregisteredRenderMembershipCount);
}
/// <summary>

View file

@ -3,9 +3,23 @@ using AcDream.App.Rendering.Scene;
using AcDream.App.Rendering.Scene.Arch;
using AcDream.App.Rendering.Walk;
using AcDream.Core.Physics;
using AcDream.Core.World;
namespace AcDream.App.Tests.Rendering.Walk;
/// <summary>
/// Campaign OVERHAUL S2 chunk 5: <see cref="WalkProductionWorldData"/> no
/// longer sweeps the scene to rebuild membership into bucket dictionaries —
/// <c>GetCellStatics</c>/<c>GetCellDynamics</c>/<c>GetOutdoorStatics</c>/
/// <c>GetOutdoorDynamics</c> are borrowed, on-demand views over
/// <see cref="ShadowObjectRegistry"/>'s retail per-cell part-entry product,
/// resolved back to a <see cref="RenderProjectionRecord"/> through
/// <see cref="RenderSceneQuery.TryGetByLocalEntityId"/>. Every test below
/// drives the REAL production path end to end — a real (bare, no-DAT)
/// <see cref="ShadowObjectRegistry"/> and a real <see cref="ArchRenderScene"/>
/// — rather than a hand-fed bucket dictionary, since the resolution itself IS
/// the thing under test now.
/// </summary>
public sealed class WalkProductionWorldDataTests
{
[Fact]
@ -79,288 +93,6 @@ public sealed class WalkProductionWorldDataTests
WalkProductionWorldData.BuildingShellBucketCellId(building));
}
[Fact]
public void BucketOutdoorRecord_UsesEveryOutdoorPhysicsShadowCellAcrossLandblockEdge()
{
RenderProjectionRecord record = Record(
id: 0x1234u,
position: new Vector3(191f, 191f, 0f));
var buckets = new Dictionary<uint, List<RenderProjectionRecord>>();
WalkProductionWorldData.BucketOutdoorRecord(
in record,
[0xF07F0040u, 0xF0800001u, 0xF4180101u],
buckets,
renderCenterLbX: 0xF0,
renderCenterLbY: 0x7F);
Assert.Equal([0xF07F0040u, 0xF0800001u], buckets.Keys.Order());
Assert.All(buckets.Values, bucket => Assert.Equal(record, Assert.Single(bucket)));
}
[Fact]
public void BucketOutdoorRecord_UnregisteredEffectFallsBackToRootPositionCell()
{
RenderProjectionRecord record = Record(
id: 0x5678u,
position: new Vector3(193f, 25f, 0f));
var buckets = new Dictionary<uint, List<RenderProjectionRecord>>();
WalkProductionWorldData.BucketOutdoorRecord(
in record,
Array.Empty<uint>(),
buckets,
renderCenterLbX: 0xEF,
renderCenterLbY: 0x7F);
Assert.Equal([0xF07F0002u], buckets.Keys);
Assert.Equal(record, Assert.Single(buckets[0xF07F0002u]));
}
[Fact]
public void BucketIndoorRecord_InstallsCrossCellPartInBothInteriorCells()
{
RenderProjectionRecord record = Record(
id: 0x48A02035u,
position: new Vector3(55.25f, -46.47f, -3f)) with
{
Source = new RenderSourceMetadata() with
{
LocalEntityId = 0x48A02035u,
ParentCellId = 0x8A02015Fu,
},
};
var indoor = new Dictionary<uint, List<RenderProjectionRecord>>();
var outdoor = new Dictionary<uint, List<RenderProjectionRecord>>();
WalkProductionWorldData.BucketIndoorRecord(
in record,
[0x8A02015Fu, 0x8A02015Eu],
indoor,
outdoor);
Assert.Equal([0x8A02015Eu, 0x8A02015Fu], indoor.Keys.Order());
Assert.All(indoor.Values, bucket => Assert.Equal(record, Assert.Single(bucket)));
Assert.Empty(outdoor);
}
[Fact]
public void BucketIndoorRecord_CanCrossAnExitIntoLandscapeCell()
{
RenderProjectionRecord record = Record(
id: 0x48A02035u,
position: Vector3.Zero) with
{
Source = new RenderSourceMetadata() with
{
LocalEntityId = 0x48A02035u,
ParentCellId = 0x8A02015Fu,
},
};
var indoor = new Dictionary<uint, List<RenderProjectionRecord>>();
var outdoor = new Dictionary<uint, List<RenderProjectionRecord>>();
WalkProductionWorldData.BucketIndoorRecord(
in record,
[0x8A02015Fu, 0x8A020021u],
indoor,
outdoor);
Assert.Equal(record, Assert.Single(indoor[0x8A02015Fu]));
Assert.Equal(record, Assert.Single(outdoor[0x8A020021u]));
}
[Fact]
public void BucketDynamicRecord_InstallsMultipartPlayerInEveryCrossedInteriorCell()
{
RenderProjectionRecord record = Record(
id: 0x000F4243u,
position: new Vector3(58.81f, -49.42f, -0.85f)) with
{
Source = new RenderSourceMetadata() with
{
LocalEntityId = 0x000F4243u,
ParentCellId = 0x8A02015Eu,
},
};
var indoor = new Dictionary<uint, List<RenderProjectionRecord>>();
var outdoor = new Dictionary<uint, List<RenderProjectionRecord>>();
WalkProductionWorldData.BucketDynamicRecord(
in record,
[0x8A02015Eu, 0x8A02015Fu, 0x8A0201C1u],
indoor,
outdoor,
renderCenterLbX: 0x8A,
renderCenterLbY: 0x02);
Assert.Equal(
[0x8A02015Eu, 0x8A02015Fu, 0x8A0201C1u],
indoor.Keys.Order());
Assert.All(
indoor.Values,
bucket => Assert.Equal(record, Assert.Single(bucket)));
Assert.Empty(outdoor);
}
[Fact]
public void BucketDynamicRecord_UnregisteredInteriorEffectFallsBackToParentCell()
{
RenderProjectionRecord record = Record(
id: 0x00001234u,
position: Vector3.Zero) with
{
Source = new RenderSourceMetadata() with
{
LocalEntityId = 0x00001234u,
ParentCellId = 0x8A02015Fu,
},
};
var indoor = new Dictionary<uint, List<RenderProjectionRecord>>();
var outdoor = new Dictionary<uint, List<RenderProjectionRecord>>();
WalkProductionWorldData.BucketDynamicRecord(
in record,
Array.Empty<uint>(),
indoor,
outdoor,
renderCenterLbX: 0x8A,
renderCenterLbY: 0x02);
Assert.Equal(record, Assert.Single(indoor[0x8A02015Fu]));
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_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: childId,
position: Vector3.Zero) with
{
Source = new RenderSourceMetadata() with
{
LocalEntityId = childId,
ParentCellId = 0xF4180104u,
},
EntityPayload = new RenderEntityPayload() with
{
CasterIdentity = RenderCasterIdentityKind.EquippedChild,
},
};
IReadOnlyList<uint> cells =
WalkProductionWorldData.ResolveDynamicRenderCells(
in wand,
id => shadows.TryGetRetailCellArray(id, out IReadOnlyList<uint> c)
? (true, c)
: (false, Array.Empty<uint>()),
shadows.GetOwnerCells,
out bool usedFallback);
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);
}
// -----------------------------------------------------------------
// Campaign OVERHAUL S2 chunk 2: BeginFrame's indoor static sweep now
// borrows membership from ShadowObjectRegistry.TryGetRetailCellArray
// instead of rebuilding it (the deleted ResolveStaticRenderCells /
// ShadowObjectRegistry.ComputeStaticRenderCells pair). These two tests
// drive the REAL production path end to end — a real (bare, no-DAT)
// ShadowObjectRegistry, a real ArchRenderScene, and BeginFrame itself —
// rather than the hardcoded-cell-array style of the Bucket* tests above,
// which exercise only the (unchanged) bucketing primitive.
// -----------------------------------------------------------------
private static ShadowShape Bsp(uint gfxObjId, float radius = 1f) =>
ShadowShape.Bsp(
gfxObjId,
@ -388,20 +120,54 @@ public sealed class WalkProductionWorldDataTests
SourceId = sourceId,
ParentCellId = parentCellId,
},
// Every production entity record carries its MeshRefs
// (RenderProjectionRecordFactory); only an EnvCell SHELL has no
// payload, and ArchRenderScene keeps shells out of the
// LocalEntityId index on exactly that distinction.
EntityPayload = new RenderEntityPayload() with
{
MeshRefs = [new MeshRef(sourceId, Matrix4x4.Identity)],
},
};
[Fact]
public void BeginFrame_IndoorStatic_RegisteredEntityUsesRegistryRetailCellArray()
{
const uint entityId = 0x48A02001u;
const uint retailCellId = 0x8A02015Fu;
// A DECOY parent cell, deliberately different from the registered
// retail array's cell, so the assertions below can only pass if the
// indoor sweep actually consulted the registry rather than falling
// back to the authored parent.
const uint decoyParentCellId = 0x8A0201C1u;
private static RenderProjectionRecord DynamicRecord(
uint entityId, uint sourceId, uint parentCellId) =>
IndoorStaticRecord(entityId, sourceId, parentCellId) with
{
Id = RenderProjectionId.FromRaw(0x0100_0000_0000_0000u | entityId),
ProjectionClass = RenderProjectionClass.LiveDynamicRoot,
};
var shadows = new ShadowObjectRegistry();
private static RenderProjectionRecord OutdoorStaticRecord(
uint entityId, uint sourceId, uint cellId, bool isBuildingShell = false) =>
new RenderProjectionRecord() with
{
Id = RenderProjectionId.FromRaw(0x0200_0000_0000_0000u | entityId),
ProjectionClass = RenderProjectionClass.OutdoorStatic,
OwnerIncarnation = RenderOwnerIncarnation.FromRaw(1),
Transform = new RenderTransform(Matrix4x4.Identity),
PreviousTransform = new PreviousRenderTransform(Matrix4x4.Identity),
Residency = new RenderSpatialResidency(
RenderSpatialBucket.FromRaw(cellId), cellId & 0xFFFF0000u, cellId),
Flags = RenderProjectionFlags.Draw,
Source = new RenderSourceMetadata() with
{
LocalEntityId = entityId,
SourceId = sourceId,
ParentCellId = cellId,
},
EntityPayload = new RenderEntityPayload() with
{
IsBuildingShell = isBuildingShell,
},
};
private static void Register(
ShadowObjectRegistry shadows,
uint entityId,
uint seedCellId,
uint landblockId)
{
IReadOnlyList<ShadowShape> parts = new[] { Bsp(0x01001234u) };
shadows.RegisterMultiPart(
entityId,
@ -412,10 +178,26 @@ public sealed class WalkProductionWorldDataTests
flags: EntityCollisionFlags.None,
worldOffsetX: 0f,
worldOffsetY: 0f,
landblockId: 0x8A020000u,
seedCellId: retailCellId,
landblockId: landblockId,
seedCellId: seedCellId,
isStatic: true,
partArray: parts);
}
[Fact]
public void GetCellStatics_RegisteredEntityIsResolvedByLocalEntityIdAtTheRetailCell()
{
const uint entityId = 0x48A02001u;
const uint retailCellId = 0x8A02015Fu;
// A DECOY parent cell, deliberately different from the registered
// retail array's cell, so the assertions below can only pass if the
// per-cell view actually consulted the registry rather than the
// authored parent (deleted alongside the sweep this test used to
// exercise).
const uint decoyParentCellId = 0x8A0201C1u;
var shadows = new ShadowObjectRegistry();
Register(shadows, entityId, retailCellId, landblockId: 0x8A020000u);
Assert.True(shadows.TryGetRetailCellArray(entityId, out IReadOnlyList<uint> retailCells));
Assert.Equal(new[] { retailCellId }, retailCells);
@ -435,34 +217,265 @@ public sealed class WalkProductionWorldDataTests
Assert.DoesNotContain(
worldData.GetCellStatics(decoyParentCellId).Records,
record => record.Id == projection.Id);
Assert.Equal(0, worldData.UnregisteredStaticRenderFallbackCount);
Assert.Equal(0, worldData.UnregisteredRenderMembershipCount);
}
[Fact]
public void BeginFrame_IndoorStatic_UnregisteredEntityFallsBackToParentCellAndCountsFallback()
public void GetCellStatics_RegistryAheadOfSceneContributesToNoCellAndCountsFallback()
{
const uint entityId = 0x48A02002u;
const uint retailCellId = 0x8A02015Fu;
// The registry HAS flooded this entity into its retail CELLARRAY —
// exactly the streaming-window race chunk 5 keeps as the ONE
// remaining fallback: the physics publisher (registry) ran before
// the presentation journal applied this frame's projected record.
var shadows = new ShadowObjectRegistry();
Register(shadows, entityId, retailCellId, landblockId: 0x8A020000u);
Assert.True(shadows.TryGetRetailCellArray(entityId, out _));
RenderSceneGeneration generation = RenderSceneGeneration.FromRaw(1);
using var scene = new ArchRenderScene(generation);
// Deliberately no scene.Apply — the projected record does not exist
// yet this frame.
var worldData = new WalkProductionWorldData(new WalkBuildingRegistry(), shadows);
worldData.BeginFrame(
scene.OpenQuery(), 0x8A020000u, renderCenterLbX: 0x8A, renderCenterLbY: 0x02);
Assert.Equal(0, worldData.GetCellStatics(retailCellId).Records.Count);
Assert.Equal(1, worldData.UnregisteredRenderMembershipCount);
}
[Fact]
public void GetCellStatics_UnregisteredEntityContributesToNoCellWithoutCounting()
{
// The OPPOSITE race: the scene has a projected record, but the
// registry never registered a retail CELLARRAY for this entity at
// all. Under the borrowed-view model this entity is never visited
// (nothing in the registry names it), so it correctly appears in NO
// cell and does not inflate the one remaining fallback counter —
// that counter only tracks entities the REGISTRY has flooded.
const uint entityId = 0x48A02003u;
const uint parentCellId = 0x8A02015Fu;
// Bare, empty registry: entityId is never registered, so
// TryGetRetailCellArray must answer false for it.
var shadows = new ShadowObjectRegistry();
Assert.False(shadows.TryGetRetailCellArray(entityId, out _));
RenderSceneGeneration generation = RenderSceneGeneration.FromRaw(1);
using var scene = new ArchRenderScene(generation);
RenderProjectionRecord projection =
IndoorStaticRecord(entityId, sourceId: 0x02000002u, parentCellId);
IndoorStaticRecord(entityId, sourceId: 0x02000003u, parentCellId);
scene.Apply([RenderProjectionDelta.Register(generation, 1, projection)]);
var worldData = new WalkProductionWorldData(new WalkBuildingRegistry(), shadows);
worldData.BeginFrame(
scene.OpenQuery(), 0x8A020000u, renderCenterLbX: 0x8A, renderCenterLbY: 0x02);
Assert.Equal(0, worldData.GetCellStatics(parentCellId).Records.Count);
Assert.Equal(0, worldData.UnregisteredRenderMembershipCount);
}
[Fact]
public void UnregisteredRenderMembershipCount_CountsDistinctEntitiesNotCellVisits()
{
// One entity crossing SEVERAL cells — outdoor, since the bbox flood's
// fixed outdoor expansion reliably crosses cells even against a bare
// registry with no portal DAT data to cross INDOOR cells with (see
// GetOutdoorStatics_UsesTheSameRegistryDrivenViewAsIndoor) — all
// unresolved in the scene, must count once total, not once per cell
// the registry flooded it into.
const uint entityId = 0x87640002u;
const uint seedCellId = 0x87640030u;
IReadOnlyList<ShadowShape> parts = new[] { Bsp(0x01001234u) };
var shadows = new ShadowObjectRegistry();
shadows.RegisterMultiPart(
entityId,
Vector3.Zero,
Quaternion.Identity,
parts,
state: 0u,
flags: EntityCollisionFlags.None,
worldOffsetX: 0f,
worldOffsetY: 0f,
landblockId: 0x87640000u,
seedCellId: seedCellId,
isStatic: true,
partArray: parts);
Assert.True(shadows.TryGetRetailCellArray(entityId, out IReadOnlyList<uint> cells));
Assert.True(cells.Count >= 2, "fixture must actually cross more than one cell");
RenderSceneGeneration generation = RenderSceneGeneration.FromRaw(1);
using var scene = new ArchRenderScene(generation);
var worldData = new WalkProductionWorldData(new WalkBuildingRegistry(), shadows);
worldData.BeginFrame(
scene.OpenQuery(), 0x87640000u, renderCenterLbX: 0x87, renderCenterLbY: 0x64);
foreach (uint cellId in cells)
Assert.Equal(0, worldData.GetOutdoorStatics(cellId).Records.Count);
Assert.Equal(1, worldData.UnregisteredRenderMembershipCount);
}
[Fact]
public void GetCellDynamics_OnlyReturnsDynamicClassRecordsFromTheSameCell()
{
const uint staticEntityId = 0x48A02010u;
const uint dynamicEntityId = 0x48A02011u;
const uint sharedCellId = 0x8A02015Fu;
var shadows = new ShadowObjectRegistry();
Register(shadows, staticEntityId, sharedCellId, landblockId: 0x8A020000u);
Register(shadows, dynamicEntityId, sharedCellId, landblockId: 0x8A020000u);
RenderSceneGeneration generation = RenderSceneGeneration.FromRaw(1);
using var scene = new ArchRenderScene(generation);
RenderProjectionRecord staticProjection =
IndoorStaticRecord(staticEntityId, sourceId: 0x02000010u, sharedCellId);
RenderProjectionRecord dynamicProjection =
DynamicRecord(dynamicEntityId, sourceId: 0x02000011u, sharedCellId);
scene.Apply([
RenderProjectionDelta.Register(generation, 1, staticProjection),
RenderProjectionDelta.Register(generation, 2, dynamicProjection),
]);
var worldData = new WalkProductionWorldData(new WalkBuildingRegistry(), shadows);
worldData.BeginFrame(
scene.OpenQuery(), 0x8A020000u, renderCenterLbX: 0x8A, renderCenterLbY: 0x02);
Assert.Contains(
worldData.GetCellStatics(parentCellId).Records,
worldData.GetCellStatics(sharedCellId).Records,
record => record.Id == staticProjection.Id);
Assert.DoesNotContain(
worldData.GetCellStatics(sharedCellId).Records,
record => record.Id == dynamicProjection.Id);
Assert.Contains(
worldData.GetCellDynamics(sharedCellId).Records,
record => record.Id == dynamicProjection.Id);
Assert.DoesNotContain(
worldData.GetCellDynamics(sharedCellId).Records,
record => record.Id == staticProjection.Id);
}
[Fact]
public void GetCellStatics_ExcludesBuildingShellRecordsFromTheSameCell()
{
const uint shellEntityId = 0x48A02020u;
const uint ordinaryEntityId = 0x48A02021u;
const uint sharedCellId = 0x8A02015Fu;
var shadows = new ShadowObjectRegistry();
Register(shadows, shellEntityId, sharedCellId, landblockId: 0x8A020000u);
Register(shadows, ordinaryEntityId, sharedCellId, landblockId: 0x8A020000u);
RenderSceneGeneration generation = RenderSceneGeneration.FromRaw(1);
using var scene = new ArchRenderScene(generation);
RenderProjectionRecord shellProjection =
IndoorStaticRecord(shellEntityId, sourceId: 0x02000020u, sharedCellId) with
{
EntityPayload = new RenderEntityPayload() with { IsBuildingShell = true },
};
RenderProjectionRecord ordinaryProjection =
IndoorStaticRecord(ordinaryEntityId, sourceId: 0x02000021u, sharedCellId);
scene.Apply([
RenderProjectionDelta.Register(generation, 1, shellProjection),
RenderProjectionDelta.Register(generation, 2, ordinaryProjection),
]);
var worldData = new WalkProductionWorldData(new WalkBuildingRegistry(), shadows);
worldData.BeginFrame(
scene.OpenQuery(), 0x8A020000u, renderCenterLbX: 0x8A, renderCenterLbY: 0x02);
Assert.DoesNotContain(
worldData.GetCellStatics(sharedCellId).Records,
record => record.Id == shellProjection.Id);
Assert.Contains(
worldData.GetCellStatics(sharedCellId).Records,
record => record.Id == ordinaryProjection.Id);
}
[Fact]
public void GetOutdoorStatics_UsesTheSameRegistryDrivenViewAsIndoor()
{
const uint entityId = 0x87640001u;
const uint outdoorCellId = 0x87640030u;
var shadows = new ShadowObjectRegistry();
Register(shadows, entityId, outdoorCellId, landblockId: 0x87640000u);
Assert.True(shadows.TryGetRetailCellArray(entityId, out IReadOnlyList<uint> retailCells));
// The outdoor bbox flood's fixed expansion crosses more than the
// seed cell even for a small radius; only the seed cell's own
// membership matters for this test.
Assert.Contains(outdoorCellId, retailCells);
RenderSceneGeneration generation = RenderSceneGeneration.FromRaw(1);
using var scene = new ArchRenderScene(generation);
RenderProjectionRecord projection =
OutdoorStaticRecord(entityId, sourceId: 0x02000030u, outdoorCellId);
scene.Apply([RenderProjectionDelta.Register(generation, 1, projection)]);
var worldData = new WalkProductionWorldData(new WalkBuildingRegistry(), shadows);
worldData.BeginFrame(
scene.OpenQuery(), 0x87640000u, renderCenterLbX: 0x87, renderCenterLbY: 0x64);
Assert.Contains(
worldData.GetOutdoorStatics(outdoorCellId).Records,
record => record.Id == projection.Id);
Assert.Equal(1, worldData.UnregisteredStaticRenderFallbackCount);
}
[Fact]
public void GetCellStatics_CachesTheResultForTheRestOfTheFrame()
{
const uint entityId = 0x48A02030u;
const uint retailCellId = 0x8A02015Fu;
var shadows = new ShadowObjectRegistry();
Register(shadows, entityId, retailCellId, landblockId: 0x8A020000u);
RenderSceneGeneration generation = RenderSceneGeneration.FromRaw(1);
using var scene = new ArchRenderScene(generation);
RenderProjectionRecord projection =
IndoorStaticRecord(entityId, sourceId: 0x02000030u, retailCellId);
scene.Apply([RenderProjectionDelta.Register(generation, 1, projection)]);
var worldData = new WalkProductionWorldData(new WalkBuildingRegistry(), shadows);
worldData.BeginFrame(
scene.OpenQuery(), 0x8A020000u, renderCenterLbX: 0x8A, renderCenterLbY: 0x02);
WalkFrameStaticRecords first = worldData.GetCellStatics(retailCellId);
WalkFrameStaticRecords second = worldData.GetCellStatics(retailCellId);
Assert.Equal(first.Records.Array, second.Records.Array);
Assert.Equal(first.Records.Offset, second.Records.Offset);
Assert.Equal(first.Records.Count, second.Records.Count);
}
[Fact]
public void StaticBucketContains_FindsARegisteredSourceIdInItsRetailCell()
{
const uint entityId = 0x48A02040u;
const uint sourceId = 0x020009A2u;
const uint retailCellId = 0xF4180112u;
var shadows = new ShadowObjectRegistry();
Register(shadows, entityId, retailCellId, landblockId: 0xF4180000u);
RenderSceneGeneration generation = RenderSceneGeneration.FromRaw(1);
using var scene = new ArchRenderScene(generation);
RenderProjectionRecord projection =
IndoorStaticRecord(entityId, sourceId, retailCellId);
scene.Apply([RenderProjectionDelta.Register(generation, 1, projection)]);
var worldData = new WalkProductionWorldData(new WalkBuildingRegistry(), shadows);
worldData.BeginFrame(
scene.OpenQuery(), 0xF4180000u, renderCenterLbX: 0xF4, renderCenterLbY: 0x18);
Assert.True(worldData.StaticBucketContains(retailCellId, sourceId));
Assert.False(worldData.StaticBucketContains(retailCellId, sourceId + 1));
Assert.False(worldData.StaticBucketContains(0xF4180107u, sourceId));
}
private static RenderProjectionRecord Record(uint id, Vector3 position) =>

View file

@ -1333,6 +1333,73 @@ public sealed class LandblockPhysicsPublisherTests
Resolved = new Dictionary<ushort, ResolvedPolygon>(),
};
/// <summary>
/// Campaign OVERHAUL S2 chunk 5 closeout (G2 self-gate finding): a DAT
/// static with visual parts but no collision geometry is still flooded
/// into its cells by retail (calc_cross_cells_static 0x00515160 bbox route
/// + add_shadows_to_cells 0x00514ae0 AddPartsShadow rows), so the static
/// publisher registers it render-only — retail cell array + part entries,
/// zero collision rows. Before this pin the publisher skipped it outright
/// and, with the walk's parent-cell fallback deleted, the Facility Hub's
/// decorative wall panels vanished.
/// </summary>
[Fact]
public void CompletePublication_NonCollidingStaticRegistersRenderOnly()
{
const uint gfxObjId = 0x01000079u;
var fixture = Fixture();
fixture.Cache.RegisterGfxObjForTest(gfxObjId, RenderOnlyGfx(radius: 0.8f));
WorldEntity entity = new()
{
Id = 0x80A9B402u,
SourceGfxObjOrSetupId = gfxObjId,
Position = new Vector3(12f, 12f, 0f),
Rotation = Quaternion.Identity,
MeshRefs = [new MeshRef(gfxObjId, Matrix4x4.Identity)],
};
Publish(fixture.Publisher, Build(FirstLandblock, [entity]));
ShadowObjectRegistry shadows = fixture.Engine.ShadowObjects;
// No collision row anywhere (Contract B: nothing to contribute).
Assert.Empty(shadows.AllEntriesForDebug());
// But the retail render product exists: cell array + one part entry.
Assert.True(shadows.TryGetRetailCellArray(entity.Id, out var cells));
Assert.NotEmpty(cells);
Assert.Equal(RetailCellArrayRoute.BoundingBox, shadows.GetRetailCellArrayRoute(entity.Id));
int matching = 0;
foreach (RetailPartEntry part in shadows.GetRetailPartEntriesInCell(cells[0]))
{
if (part.EntityId != entity.Id)
continue;
matching++;
Assert.Equal(gfxObjId, part.GfxObjId);
}
Assert.Equal(1, matching);
Assert.Equal(0, fixture.Publisher.Diagnostics.StaticBspOwnerCount);
Assert.Equal(0, fixture.Publisher.Diagnostics.StaticCylinderOwnerCount);
}
/// <summary>A GfxObj with visual bounds and NO physics BSP — a purely
/// decorative static part.</summary>
private static GfxObjPhysics RenderOnlyGfx(float radius) => new()
{
BoundingSphere = new Sphere
{
Origin = Vector3.Zero,
Radius = radius,
},
PhysicsPolygons = new Dictionary<ushort, Polygon>(),
Vertices = new VertexArray(),
Resolved = new Dictionary<ushort, ResolvedPolygon>(),
VisualBounds = new FlatGfxObjVisualBounds(
new Vector3(-radius),
new Vector3(radius),
Vector3.Zero,
radius,
new Vector3(radius)),
};
private static GfxObj PhysicsGfx() => new()
{
Flags = DatReaderWriter.Enums.GfxObjFlags.HasPhysics,

View file

@ -108,4 +108,77 @@ public sealed class LandblockPhysicsContentBuilderStaticSphereTests
Assert.Equal(expectedOrdered[i].CylHeight, entries[i].CylHeight);
}
}
/// <summary>
/// Campaign OVERHAUL S2 chunk 5 closeout (G2 self-gate finding): a DAT
/// static whose GfxObj has visual geometry but no physics BSP is still a
/// CPhysicsObj retail floods into its cells (calc_cross_cells_static
/// 0x00515160 bbox route over the part array + add_shadows_to_cells
/// 0x00514ae0 AddPartsShadow rows), so the Content publisher registers it
/// render-only: retail cell array + part entry, zero collision rows, and
/// it still counts as a no-collision owner.
/// </summary>
[Fact]
public void PublishStaticCollision_NonCollidingGfxObjStatic_RegistersRenderOnly()
{
const uint gfxObjId = 0x01000043u;
var gfx = new GfxObj
{
VertexArray = new VertexArray
{
Vertices = new Dictionary<ushort, SWVertex>
{
[0] = new SWVertex { Origin = new Vector3(-0.5f, -0.5f, 0f) },
[1] = new SWVertex { Origin = new Vector3(0.5f, -0.5f, 0f) },
[2] = new SWVertex { Origin = new Vector3(0f, 0.5f, 1.2f) },
},
},
};
FlatGfxObjCollisionAsset asset = FlatCollisionAssetBuilder.FlattenGfxObj(gfx);
Assert.True(asset.PhysicsBsp.RootIndex < 0); // the fixture really has no physics
Assert.NotNull(asset.VisualBounds);
var entity = new WorldEntity
{
Id = 0x80A9B402u,
SourceGfxObjOrSetupId = gfxObjId,
Position = new Vector3(12f, 12f, 0f),
Rotation = Quaternion.Identity,
MeshRefs = new[] { new MeshRef(gfxObjId, Matrix4x4.Identity) },
};
var landblock = new LoadedLandblock(
LandblockId,
new LandBlock { Terrain = new TerrainInfo[81], Height = new byte[81] },
new[] { entity });
var collisions = new LandblockCollisionBuild(
ImmutableDictionary<uint, FlatGfxObjCollisionAsset>.Empty.Add(gfxObjId, asset),
ImmutableDictionary<uint, FlatSetupCollision>.Empty,
ImmutableDictionary<uint, FlatCellStructureCollisionAsset>.Empty,
ImmutableDictionary<uint, FlatEnvCellTopology>.Empty,
ImmutableArray.Create(gfxObjId),
ImmutableArray<uint>.Empty,
ImmutableArray<uint>.Empty);
var engine = new PhysicsEngine();
var cache = new PhysicsDataCache();
LandblockPhysicsContentBuilder.CachePreparedObjects(cache, collisions);
LandblockPhysicsContentBuilder.StaticCollisionPublication publication =
LandblockPhysicsContentBuilder.PublishStaticCollision(
engine, cache, landblock, collisions, origin: Vector3.Zero);
Assert.Equal(0, publication.SetupOwnerCount);
Assert.Equal(0, publication.BspOwnerCount);
Assert.Equal(1, publication.NoCollisionCount);
Assert.Empty(engine.ShadowObjects.AllEntriesForDebug());
Assert.True(engine.ShadowObjects.TryGetRetailCellArray(entity.Id, out var cells));
Assert.NotEmpty(cells);
Assert.Equal(
RetailCellArrayRoute.BoundingBox,
engine.ShadowObjects.GetRetailCellArrayRoute(entity.Id));
var parts = engine.ShadowObjects.GetRetailPartEntriesInCell(cells[0])
.Where(part => part.EntityId == entity.Id)
.ToArray();
Assert.Single(parts);
Assert.Equal(gfxObjId, parts[0].GfxObjId);
}
}

View file

@ -867,4 +867,388 @@ public class ShadowObjectRegistryRetailCellArrayTests
Assert.True(reg.TryGetRetailCellArray(childId, out var childCells));
Assert.Equal(rootCellsAfter, childCells);
}
// -------------------------------------------------------------------
// Campaign OVERHAUL S2 chunk 5 (item B): a live entity whose collision
// dispatch produced NO shapes but whose Setup still has a visual part
// array (the retail short-lived spell/visual effect-object case) still
// registers for render — Contract A's cylsphere-vs-bbox TEST reads the
// COLLISION shapes (none here, so it is always false) and the flood
// unconditionally falls through to the bbox route over the WHOLE part
// array. No ShadowEntry collision row is ever published; only the
// retail CELLARRAY and per-cell RetailPartEntry rows are.
// -------------------------------------------------------------------
[Fact]
public void RegisterMultiPart_EmptyShapesWithPartArray_RegistersRenderOnlyNoCollisionRows()
{
var reg = new ShadowObjectRegistry();
const uint entityId = 0x50u;
IReadOnlyList<ShadowShape> partArray = new[] { Bsp(0x0100_0060u, radius: 2f) };
reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, Array.Empty<ShadowShape>(),
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
seedCellId: CellA, isStatic: true, partArray: partArray);
Assert.True(reg.TryGetRetailCellArray(entityId, out var cells));
Assert.Equal(new[] { CellA }, cells);
Assert.Equal(RetailCellArrayRoute.BoundingBox, reg.GetRetailCellArrayRoute(entityId));
var entries = reg.GetRetailPartEntriesInCell(CellA)
.Where(e => e.EntityId == entityId).ToList();
Assert.Single(entries);
Assert.Equal(0x0100_0060u, entries[0].GfxObjId);
// No collision row anywhere: shadow_object_list gets nothing for an
// object with no part to contribute to it (Contract B).
Assert.Empty(reg.GetOwnerCells(entityId));
}
[Fact]
public void RegisterMultiPart_EmptyShapesAndNoPartArray_StillDeregisters()
{
// Byte-for-byte prior behavior: a caller that supplies neither
// collision shapes nor a part array is a genuine deregistration, not
// a render-only registration.
var reg = new ShadowObjectRegistry();
const uint entityId = 0x51u;
IReadOnlyList<ShadowShape> parts = new[] { Bsp(0x0100_0061u) };
reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, parts,
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
seedCellId: CellA, isStatic: true, partArray: parts);
Assert.True(reg.TryGetRetailCellArray(entityId, out _));
reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, Array.Empty<ShadowShape>(),
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
seedCellId: CellA, isStatic: true);
Assert.False(reg.TryGetRetailCellArray(entityId, out var cells));
Assert.Empty(cells);
Assert.Empty(reg.GetOwnerCells(entityId));
Assert.Empty(reg.GetRetailPartEntriesInCell(CellA));
}
[Fact]
public void RegisterMultiPart_RenderOnly_KeepWhenEmptyPreservesThePriorRegistration()
{
var reg = new ShadowObjectRegistry();
const uint entityId = 0x52u;
IReadOnlyList<ShadowShape> partArray = new[] { Bsp(0x0100_0062u, radius: 2f) };
reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, Array.Empty<ShadowShape>(),
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
seedCellId: CellA, isStatic: true, partArray: partArray);
Assert.True(reg.TryGetRetailCellArray(entityId, out var before));
Assert.Equal(new[] { CellA }, before);
// landblockId 0 (no seedCellId either) => DeriveOutdoorSeed returns 0
// => the retail keep-when-empty gate (pc:283540): neither product is
// touched.
reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, Array.Empty<ShadowShape>(),
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, landblockId: 0u,
isStatic: true, partArray: partArray);
Assert.True(reg.TryGetRetailCellArray(entityId, out var after));
Assert.Equal(before, after);
Assert.NotEmpty(reg.GetRetailPartEntriesInCell(CellA));
}
[Fact]
public void RegisterMultiPart_RenderOnly_DeregisterClearsEveryProduct()
{
var reg = new ShadowObjectRegistry();
const uint entityId = 0x53u;
IReadOnlyList<ShadowShape> partArray = new[] { Bsp(0x0100_0063u, radius: 2f) };
reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, Array.Empty<ShadowShape>(),
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
seedCellId: CellA, isStatic: true, partArray: partArray);
Assert.True(reg.TryGetRetailCellArray(entityId, out _));
reg.Deregister(entityId);
Assert.False(reg.TryGetRetailCellArray(entityId, out var cells));
Assert.Empty(cells);
Assert.Empty(reg.GetRetailPartEntriesInCell(CellA));
}
[Fact]
public void UpdatePosition_RenderOnly_RecomputesTheRetailCellArrayAtTheNewPosition()
{
var reg = new ShadowObjectRegistry();
const uint entityId = 0x54u;
IReadOnlyList<ShadowShape> partArray = new[] { Bsp(0x0100_0064u, radius: 2f) };
reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, Array.Empty<ShadowShape>(),
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
seedCellId: CellA, isStatic: true, partArray: partArray);
Assert.True(reg.TryGetRetailCellArray(entityId, out var before));
Assert.Equal(new[] { CellA }, before);
var moved = new Vector3(42f, 12f, 50f); // 30 m away in X -> CellB
reg.UpdatePosition(entityId, moved, Quaternion.Identity, OffX, OffY, LbId);
Assert.True(reg.TryGetRetailCellArray(entityId, out var after));
Assert.Equal(new[] { CellB }, after);
Assert.DoesNotContain(
reg.GetRetailPartEntriesInCell(CellA),
e => e.EntityId == entityId);
var movedEntries = reg.GetRetailPartEntriesInCell(CellB)
.Where(e => e.EntityId == entityId).ToList();
Assert.Single(movedEntries);
Assert.Equal(0x0100_0064u, movedEntries[0].GfxObjId);
// Still no collision row after the move.
Assert.Empty(reg.GetOwnerCells(entityId));
}
// -------------------------------------------------------------------
// Chunk 5 closeout: a streaming reflood (RefloodOwnerForLandblock, the
// path landblock replacement commits and the Content builder's
// post-publication reflood both take) is retail's recalc_cross_cells
// over the SAME CPartArray, so the retail render product survives it.
// Before this pin the reflood re-registered from the collision shapes
// only and every reflooded owner vanished from the walk.
// -------------------------------------------------------------------
[Fact]
public void RefloodLandblock_KeepsTheRetailProductOfACollisionOwnerWithAPartArray()
{
var reg = new ShadowObjectRegistry();
const uint entityId = 0x60u;
IReadOnlyList<ShadowShape> collision = new[] { Bsp(0x0100_0070u, radius: 2f) };
IReadOnlyList<ShadowShape> parts = new[]
{
Bsp(0x0100_0070u, radius: 2f),
Bsp(0x0100_0071u, localPosition: new Vector3(0.5f, 0f, 0f), radius: 1f),
};
reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, collision,
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
seedCellId: CellA, isStatic: true, partArray: parts);
Assert.True(reg.TryGetRetailCellArray(entityId, out var before));
var beforeEntries = reg.GetRetailPartEntriesInCell(CellA)
.Where(e => e.EntityId == entityId).Select(e => e.GfxObjId).ToList();
Assert.Equal(new[] { 0x0100_0070u, 0x0100_0071u }, beforeEntries);
reg.RefloodLandblock(LbId);
Assert.True(reg.TryGetRetailCellArray(entityId, out var after));
Assert.Equal(before, after);
Assert.Equal(RetailCellArrayRoute.BoundingBox, reg.GetRetailCellArrayRoute(entityId));
var afterEntries = reg.GetRetailPartEntriesInCell(CellA)
.Where(e => e.EntityId == entityId).Select(e => e.GfxObjId).ToList();
Assert.Equal(beforeEntries, afterEntries);
// The collision product is untouched by the reflood as well.
Assert.Equal(new[] { CellA }, reg.GetOwnerCells(entityId));
}
[Fact]
public void RefloodLandblock_KeepsARenderOnlyOwner()
{
var reg = new ShadowObjectRegistry();
const uint entityId = 0x61u;
IReadOnlyList<ShadowShape> parts = new[] { Bsp(0x0100_0072u, radius: 2f) };
reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, Array.Empty<ShadowShape>(),
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
seedCellId: CellA, isStatic: true, partArray: parts);
Assert.True(reg.TryGetRetailCellArray(entityId, out _));
reg.RefloodLandblock(LbId);
// Still registered, still render-only, still in its cell: the empty
// collision shape list plus the retained part array is the
// render-only registration, not the "nothing supplied" deregistration.
Assert.True(reg.TryGetRetailCellArray(entityId, out var cells));
Assert.Equal(new[] { CellA }, cells);
var entries = reg.GetRetailPartEntriesInCell(CellA)
.Where(e => e.EntityId == entityId).ToList();
Assert.Single(entries);
Assert.Equal(0x0100_0072u, entries[0].GfxObjId);
Assert.Empty(reg.GetOwnerCells(entityId));
}
// -------------------------------------------------------------------
// S2 review fix round (2026-09-03): lifetime symmetry of the retail
// render product. remove_shadows_from_cells (0x00511230) removes the
// CShadowObj row AND the AddPartsShadow rows in one transaction, and
// recurses through children — every acdream path that drops one product
// drops the other.
// -------------------------------------------------------------------
private static int CountRows(ShadowObjectRegistry reg, uint cellId, uint entityId)
=> reg.GetRetailPartEntriesInCell(cellId).Count(e => e.EntityId == entityId);
[Fact]
public void Suspend_ClearsTheRetailProduct_AndTheUnsuspendingMoveRepublishesIt()
{
var reg = new ShadowObjectRegistry();
const uint entityId = 0x70u;
IReadOnlyList<ShadowShape> parts = new[] { Bsp(0x0100_0080u, radius: 2f) };
reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, parts,
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
seedCellId: CellA, isStatic: false, partArray: parts);
Assert.Equal(1, CountRows(reg, CellA, entityId));
Assert.True(reg.Suspend(entityId));
Assert.False(reg.TryGetRetailCellArray(entityId, out _));
Assert.Equal(0, CountRows(reg, CellA, entityId));
Assert.Empty(reg.GetOwnerCells(entityId));
// The route and part array are retained for the republish.
Assert.Equal(RetailCellArrayRoute.BoundingBox, reg.GetRetailCellArrayRoute(entityId));
// A .None commit republishes at the RETAINED (suspended) cells for
// both products — the un-suspend path.
reg.CommitSetPosition(
entityId, Pos, Quaternion.Identity,
seedCellId: CellA, worldOffsetX: OffX, worldOffsetY: OffY,
action: PhysicsShadowCommitAction.None,
crossCellIds: ImmutableArray<uint>.Empty);
Assert.Equal(new[] { CellA }, reg.GetOwnerCells(entityId));
Assert.True(reg.TryGetRetailCellArray(entityId, out var cells));
Assert.Equal(new[] { CellA }, cells);
Assert.Equal(1, CountRows(reg, CellA, entityId));
}
[Fact]
public void AttachChild_AndDetachChild_AdvanceTheMutationRevision()
{
var reg = new ShadowObjectRegistry();
const uint rootId = 0x71u;
const uint childId = 0x72u;
IReadOnlyList<ShadowShape> rootParts = new[] { Bsp(0x0100_0081u, radius: 2f) };
reg.RegisterMultiPart(rootId, Pos, Quaternion.Identity, rootParts,
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
seedCellId: CellA, isStatic: false, partArray: rootParts);
ulong before = reg.MutationRevision;
Assert.True(reg.AttachChild(childId, rootId, new[] { Bsp(0x0100_0082u) }));
Assert.True(reg.MutationRevision > before);
before = reg.MutationRevision;
Assert.True(reg.DetachChild(childId));
Assert.True(reg.MutationRevision > before);
}
[Fact]
public void RegisterMultiPart_OfAnAttachedChild_KeepsInheritingTheRootsCells()
{
var reg = new ShadowObjectRegistry();
const uint rootId = 0x73u;
const uint childId = 0x74u;
IReadOnlyList<ShadowShape> rootParts = new[] { Bsp(0x0100_0083u, radius: 2f) };
reg.RegisterMultiPart(rootId, Pos, Quaternion.Identity, rootParts,
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
seedCellId: CellA, isStatic: false, partArray: rootParts);
Assert.True(reg.AttachChild(childId, rootId, new[] { Bsp(0x0100_0084u) }));
Assert.Equal(1, CountRows(reg, CellA, childId));
// An appearance update re-registers the child at its own (different)
// position with a NEW part array: retail never floods a child, so it
// keeps the root's CELLARRAY and only its parts change.
IReadOnlyList<ShadowShape> newParts = new[] { Bsp(0x0100_0085u, radius: 1f) };
reg.RegisterMultiPart(childId, new Vector3(42f, 12f, 50f), Quaternion.Identity, newParts,
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
seedCellId: CellB, isStatic: false, partArray: newParts);
Assert.True(reg.TryGetRetailCellArray(childId, out var childCells));
Assert.Equal(new[] { CellA }, childCells);
Assert.Equal(0, CountRows(reg, CellB, childId));
var rows = reg.GetRetailPartEntriesInCell(CellA).Where(e => e.EntityId == childId).ToList();
Assert.Single(rows);
Assert.Equal(0x0100_0085u, rows[0].GfxObjId);
}
[Fact]
public void RemoveLandblock_ClearsTheRetailProduct_IncludingRenderOnlyStatics()
{
var reg = new ShadowObjectRegistry();
const uint collidingId = 0x75u;
const uint renderOnlyId = 0x76u;
IReadOnlyList<ShadowShape> parts = new[] { Bsp(0x0100_0086u, radius: 2f) };
reg.RegisterMultiPart(collidingId, Pos, Quaternion.Identity, parts,
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
seedCellId: CellA, isStatic: true, partArray: parts);
reg.RegisterMultiPart(renderOnlyId, Pos, Quaternion.Identity, Array.Empty<ShadowShape>(),
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
seedCellId: CellA, isStatic: true, partArray: parts);
Assert.Equal(1, CountRows(reg, CellA, renderOnlyId));
reg.RemoveLandblock(LbId);
Assert.False(reg.TryGetRetailCellArray(collidingId, out _));
Assert.False(reg.TryGetRetailCellArray(renderOnlyId, out _));
Assert.Empty(reg.GetRetailPartEntriesInCell(CellA));
// Both statics ended with their landblock — nothing left to suspend.
Assert.False(reg.Suspend(collidingId));
Assert.False(reg.Suspend(renderOnlyId));
}
[Fact]
public void RetireOwnerFromLandblock_PrunesTheRetailRowsOfANonRootedOwner()
{
var reg = new ShadowObjectRegistry();
const uint entityId = 0x77u;
IReadOnlyList<ShadowShape> parts = new[] { Bsp(0x0100_0087u, radius: 2f) };
reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, parts,
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
seedCellId: CellA, isStatic: false, partArray: parts);
Assert.Equal(1, CountRows(reg, CellA, entityId));
reg.RetireOwnerFromLandblock(entityId, LbId);
Assert.Equal(0, CountRows(reg, CellA, entityId));
Assert.False(reg.TryGetRetailCellArray(entityId, out _));
Assert.Empty(reg.GetOwnerCells(entityId));
// A live owner survives the prefix retirement (it is still logically
// alive for the reload reflood), so it can still be suspended.
Assert.True(reg.Suspend(entityId));
}
[Fact]
public void ReplaceMultiPartPayload_WithAnEmptyPartArray_KeepsThePriorRows()
{
var reg = new ShadowObjectRegistry();
const uint entityId = 0x78u;
IReadOnlyList<ShadowShape> parts = new[] { Bsp(0x0100_0088u, radius: 2f) };
reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, parts,
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
seedCellId: CellA, isStatic: false, partArray: parts);
reg.ReplaceMultiPartPayload(entityId, Pos, Quaternion.Identity, parts,
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
seedCellId: CellA, isStatic: false, partArray: Array.Empty<ShadowShape>());
// Never a cell-array claim with no drawable rows behind it.
Assert.True(reg.TryGetRetailCellArray(entityId, out var cells));
Assert.Equal(new[] { CellA }, cells);
Assert.Equal(1, CountRows(reg, CellA, entityId));
}
[Fact]
public void CommitSetPosition_None_MovesARenderOnlyOwnerToItsDestinationCell()
{
var reg = new ShadowObjectRegistry();
const uint entityId = 0x79u;
IReadOnlyList<ShadowShape> parts = new[] { Bsp(0x0100_0089u, radius: 1f) };
reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, Array.Empty<ShadowShape>(),
state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId,
seedCellId: CellA, isStatic: false, partArray: parts);
Assert.Equal(1, CountRows(reg, CellA, entityId));
// A render-only owner has no collision cells to republish from, so
// its "cell did not change" commit would otherwise freeze it at its
// registration cells forever (retail review F3): it travels with its
// destination cell instead (AD-117).
reg.CommitSetPosition(
entityId, new Vector3(42f, 12f, 50f), Quaternion.Identity,
seedCellId: CellB, worldOffsetX: OffX, worldOffsetY: OffY,
action: PhysicsShadowCommitAction.None,
crossCellIds: ImmutableArray<uint>.Empty);
Assert.True(reg.TryGetRetailCellArray(entityId, out var cells));
Assert.Equal(new[] { CellB }, cells);
Assert.Equal(0, CountRows(reg, CellA, entityId));
Assert.Equal(1, CountRows(reg, CellB, entityId));
Assert.Empty(reg.GetOwnerCells(entityId));
}
}