using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; using System.Numerics; using AcDream.Core.Physics; using Xunit; namespace AcDream.Core.Tests.Physics; /// /// Campaign OVERHAUL S2 chunk 1 /// (docs/research/2026-09-01-overhaul/s2-membership-ownership-map.md /// §3): the retail CELLARRAY and per-cell part-entry SIDE PRODUCT /// now computes from an optional whole /// part array, beside the existing collision flood. Retail anchors: /// CPhysicsObj::calc_cross_cells_static 0x00515160 (Contract A branch /// table), CPhysicsObj::find_bbox_cell_list 0x00510fc0, /// CPartArray::AddPartsShadow 0x00517e40 (Contract B), /// CPhysicsObj::remove_shadows_from_cells 0x00511230, /// CEnvCell::find_transit_cells 0x0052cae0 — all re-verified /// 2026-09-01 through the live Ghidra bridge against patchmem.gpr /// (docs/research/2026-09-01-overhaul/oh1-construction-landscape-contract.md). /// /// /// A bare (no installed DAT) floods /// against an empty PhysicsDataCache: an outdoor seed still produces /// the real overlapped landcells from pure LandDefs math (the same fixture /// style and /// ShadowObjectRegistryMultiPartTests already use), so every fixture /// here stays synthetic — no installed-DAT dependency. /// /// public class ShadowObjectRegistryRetailCellArrayTests { private const uint LbId = 0xA9B40000u; private const float OffX = 0f; private const float OffY = 0f; // World (12, 12, 50) -> local cell (cx=0, cy=0) -> id = LbId | 1. private const uint CellA = LbId | 1u; // World (42, 12, 50), 30 m away in X -> local cell (cx=1, cy=0) -> id = LbId | 9. private const uint CellB = LbId | 9u; private static readonly Vector3 Pos = new(12f, 12f, 50f); private static ShadowShape Bsp(uint gfxObjId, Vector3 localPosition = default, float radius = 1f) => ShadowShape.Bsp( gfxObjId, localPosition, Quaternion.Identity, scale: 1f, localGeometry: ShadowPartGeometry.Create( new FlatCollisionSphere(Vector3.Zero, radius), null)); private static ShadowShape Cyl(uint gfxObjId, Vector3 localPosition = default, float radius = 1f) => ShadowShape.Cylinder( gfxObjId, localPosition, Quaternion.Identity, scale: 1f, radius: radius, cylHeight: 1f); // ------------------------------------------------------------------- // Item E: existing callers (no partArray) are byte-for-byte unaffected. // ------------------------------------------------------------------- [Fact] public void RegisterMultiPart_WithoutPartArray_NoRetailProduct() { var reg = new ShadowObjectRegistry(); const uint entityId = 0x10u; var shapes = new[] { Bsp(0x0100_0001u) }; reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, shapes, state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId); Assert.False(reg.TryGetRetailCellArray(entityId, out var cells)); Assert.Empty(cells); Assert.Equal(RetailCellArrayRoute.None, reg.GetRetailCellArrayRoute(entityId)); foreach (uint cellId in reg.GetOwnerCells(entityId)) Assert.Empty(reg.GetRetailPartEntriesInCell(cellId)); } [Fact] public void Register_SingleShapeWithoutPartArray_NoRetailProduct() { var reg = new ShadowObjectRegistry(); const uint entityId = 0x11u; reg.Register(entityId, 0x0100_0002u, Pos, Quaternion.Identity, 1f, OffX, OffY, LbId); Assert.False(reg.TryGetRetailCellArray(entityId, out var cells)); Assert.Empty(cells); Assert.Equal(RetailCellArrayRoute.None, reg.GetRetailCellArrayRoute(entityId)); } // ------------------------------------------------------------------- // The bbox route (find_bbox_cell_list 0x00510fc0) is the SAME primitive // the collision flood uses for a single BSP-shape part array fed as both // `shapes` and `partArray`, and supplying a part array never touches the // existing collision product. // ------------------------------------------------------------------- [Fact] public void RegisterMultiPart_WithPartArray_BoundingBoxRouteMatchesCollisionFloodForTheSamePartArray() { var withPartArray = new ShadowObjectRegistry(); var withoutPartArray = new ShadowObjectRegistry(); const uint entityId = 0x12u; IReadOnlyList parts = new[] { Bsp(0x0100_0044u, radius: 2f) }; withPartArray.RegisterMultiPart(entityId, Pos, Quaternion.Identity, parts, state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId, seedCellId: CellA, isStatic: true, partArray: parts); withoutPartArray.RegisterMultiPart(entityId, Pos, Quaternion.Identity, parts, state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId, seedCellId: CellA, isStatic: true); Assert.Equal(RetailCellArrayRoute.BoundingBox, withPartArray.GetRetailCellArrayRoute(entityId)); Assert.True(withPartArray.TryGetRetailCellArray(entityId, out var retailCells)); Assert.NotEmpty(retailCells); // the fixture must actually exercise a flood // Retail's bbox route and RegisterMultiPart's own BSP collision // dispatch share one primitive (CPhysicsObj::find_bbox_cell_list // 0x00510fc0) when fed the identical single-part array as both // `shapes` and `partArray`, so the two products agree exactly. This // is the independent ground truth now that ComputeStaticRenderCells // (Campaign OVERHAUL S2 chunk 2) is deleted along with its only // production consumer. Assert.Equal(withPartArray.GetOwnerCells(entityId), retailCells); // Supplying a part array must not perturb the pre-existing collision // product — same registration, same result either way. Assert.Equal(withoutPartArray.GetOwnerCells(entityId), withPartArray.GetOwnerCells(entityId)); } // ------------------------------------------------------------------- // Contract A branch table: (state & 0x10000) == 0 && has a Cylinder // shape -> cylsphere route; otherwise (including the same part array // with the state bit set) -> bbox route. // ------------------------------------------------------------------- [Fact] public void RegisterMultiPart_PartArrayRoute_DispatchesOnStateBitAndCylinderPresence() { var reg = new ShadowObjectRegistry(); const uint entityId = 0x13u; IReadOnlyList mixed = new[] { Cyl(0x3001u), // part 0: at the entity origin (CellA) Bsp(0x3002u, new Vector3(30f, 0f, 0f)), // part 1: 30 m away in X (CellB) }; // State bit clear + a Cylinder present -> cylsphere route // (CObjCell::find_cell_list 0x0052b9f0), flooded from the cylinder // only — the bsp part 30 m away must not pull in CellB. reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, mixed, state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId, partArray: mixed); Assert.Equal(RetailCellArrayRoute.Cylsphere, reg.GetRetailCellArrayRoute(entityId)); Assert.True(reg.TryGetRetailCellArray(entityId, out var cylCells)); Assert.Equal(new[] { CellA }, cylCells); var cylEntries = reg.GetRetailPartEntriesInCell(CellA) .Where(e => e.EntityId == entityId) .OrderBy(e => e.PartIndex) .ToList(); // AddPartsShadow registers EVERY part per cell, not just the ones // that contributed to crossing it (Contract B). Assert.Equal(2, cylEntries.Count); Assert.Equal(0, cylEntries[0].PartIndex); Assert.Equal(0x3001u, cylEntries[0].GfxObjId); Assert.Equal(1, cylEntries[1].PartIndex); Assert.Equal(0x3002u, cylEntries[1].GfxObjId); Assert.False(cylEntries[0].ClipPlanesRequired); // single-cell CELLARRAY Assert.False(cylEntries[1].ClipPlanesRequired); // Re-register the SAME part array with the HAS_PHYSICS_BSP_PS state // bit set -> bbox route (CPhysicsObj::find_bbox_cell_list 0x00510fc0) // regardless of the cylinder's presence, flooded from the surviving // bsp part -> pulls in CellB too. reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, mixed, state: 0x10000u, flags: EntityCollisionFlags.None, OffX, OffY, LbId, partArray: mixed); Assert.Equal(RetailCellArrayRoute.BoundingBox, reg.GetRetailCellArrayRoute(entityId)); Assert.True(reg.TryGetRetailCellArray(entityId, out var bboxCells)); Assert.Equal(new[] { CellA, CellB }, bboxCells); foreach (uint cellId in bboxCells) { var entries = reg.GetRetailPartEntriesInCell(cellId) .Where(e => e.EntityId == entityId) .OrderBy(e => e.PartIndex) .ToList(); Assert.Equal(2, entries.Count); Assert.Equal(0, entries[0].PartIndex); Assert.Equal(1, entries[1].PartIndex); // More than one CELLARRAY member -> clip planes required (Contract B). Assert.True(entries[0].ClipPlanesRequired); Assert.True(entries[1].ClipPlanesRequired); } } [Fact] public void RegisterMultiPart_RouteIsDecidedByCollisionCylspheres_NotByTheVisualPartArray() { // Contract A: GetNumCylsphere() reads the Setup's authored CylSpheres, // which acdream carries as the Cylinder shapes of the COLLISION // dispatch. A visual part array (chunk 1b passes visual parts, which // are never cylinders) must not change the route. var reg = new ShadowObjectRegistry(); const uint entityId = 0x14u; IReadOnlyList collision = new[] { Cyl(0x3001u) }; IReadOnlyList visualParts = new[] { Bsp(0x3002u, new Vector3(30f, 0f, 0f)), // a visual part 30 m away (CellB) }; reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, collision, state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId, partArray: visualParts); // Cylsphere route: flooded from the authored cylsphere at the origin, // not from the far visual part. Assert.Equal(RetailCellArrayRoute.Cylsphere, reg.GetRetailCellArrayRoute(entityId)); Assert.True(reg.TryGetRetailCellArray(entityId, out var cells)); Assert.Equal(new[] { CellA }, cells); // ...but the entries are the VISUAL parts (AddPartsShadow walks the part array). var entries = reg.GetRetailPartEntriesInCell(CellA).Where(e => e.EntityId == entityId).ToList(); Assert.Single(entries); Assert.Equal(0x3002u, entries[0].GfxObjId); // No cylsphere in the collision dispatch -> bbox route over the visual parts. IReadOnlyList bspCollision = new[] { Bsp(0x3001u, Vector3.Zero) }; reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, bspCollision, state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId, partArray: visualParts); Assert.Equal(RetailCellArrayRoute.BoundingBox, reg.GetRetailCellArrayRoute(entityId)); } // ------------------------------------------------------------------- // Deregister is the exact inverse transaction. // ------------------------------------------------------------------- [Fact] public void Deregister_ClearsRetailCellArrayAndEveryPerCellEntry() { var reg = new ShadowObjectRegistry(); const uint entityId = 0x14u; IReadOnlyList parts = new[] { Cyl(0x4001u), Bsp(0x4002u, new Vector3(30f, 0f, 0f)), }; reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, parts, state: 0x10000u, flags: EntityCollisionFlags.None, OffX, OffY, LbId, partArray: parts); Assert.True(reg.TryGetRetailCellArray(entityId, out var cellsBeforeRemoval)); Assert.NotEmpty(cellsBeforeRemoval); var trackedCells = cellsBeforeRemoval.ToArray(); reg.Deregister(entityId); Assert.False(reg.TryGetRetailCellArray(entityId, out var cellsAfterRemoval)); Assert.Empty(cellsAfterRemoval); Assert.Equal(RetailCellArrayRoute.None, reg.GetRetailCellArrayRoute(entityId)); foreach (uint cellId in trackedCells) Assert.Empty(reg.GetRetailPartEntriesInCell(cellId)); } // ------------------------------------------------------------------- // UpdatePosition / CommitSetPosition recompute the retained product at // the new seed/position — the same flood rules, run again. // ------------------------------------------------------------------- [Fact] public void UpdatePosition_WithRetainedPartArray_RecomputesRetailCellArray() { var reg = new ShadowObjectRegistry(); const uint entityId = 0x15u; IReadOnlyList parts = new[] { Cyl(0x5001u) }; reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, parts, state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId, partArray: parts); 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); var movedEntries = reg.GetRetailPartEntriesInCell(CellB) .Where(e => e.EntityId == entityId).ToList(); Assert.Single(movedEntries); Assert.Equal(0x5001u, movedEntries[0].GfxObjId); Assert.DoesNotContain( reg.GetRetailPartEntriesInCell(CellA), e => e.EntityId == entityId); } // ------------------------------------------------------------------- // Campaign OVERHAUL S2 chunk 4: "one array, two products" on the move // path too. Retail's SetPositionInternal (pc:283399) republishes BOTH // the collision CShadowObj list and the retail render product from the // SAME transition cell_array (pc:283536-283537) — never a second, // independently-flooded array. The retail array must therefore equal // GetOwnerCells after every CommitSetPosition variant. // ------------------------------------------------------------------- [Fact] public void CommitSetPosition_NoneAction_PublishesRetailProductFromTheRetainedCells() { var reg = new ShadowObjectRegistry(); const uint entityId = 0x16u; IReadOnlyList parts = new[] { Cyl(0x6001u) }; reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, parts, state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId, isStatic: false, partArray: parts); Assert.True(reg.TryGetRetailCellArray(entityId, out var before)); Assert.Equal(new[] { CellA }, before); // A .None commit carries no transition cell_array of its own (it // maps to retail's "cell did not change" branch, e.g. ForceIntoCell // with changedCell == false) — CommitSetPosition's None action // republishes at the RETAINED cells (CellA), not a fresh flood from // the new world position, even though (42,12,50) would geometrically // resolve to CellB. Both products must agree either way. var moved = new Vector3(42f, 12f, 50f); reg.CommitSetPosition( entityId, moved, Quaternion.Identity, seedCellId: CellB, worldOffsetX: OffX, worldOffsetY: OffY, action: PhysicsShadowCommitAction.None, crossCellIds: ImmutableArray.Empty); Assert.Equal(new[] { CellA }, reg.GetOwnerCells(entityId)); Assert.True(reg.TryGetRetailCellArray(entityId, out var after)); Assert.Equal(reg.GetOwnerCells(entityId), after); } [Fact] public void CommitSetPosition_ReplaceAction_PublishesRetailProductFromTheTransitionCellArray() { var reg = new ShadowObjectRegistry(); const uint entityId = 0x18u; IReadOnlyList parts = new[] { Cyl(0x6101u) }; reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, parts, state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId, isStatic: false, partArray: parts); Assert.True(reg.TryGetRetailCellArray(entityId, out var before)); Assert.Equal(new[] { CellA }, before); // .Replace supplies the transition's own crossCellIds — retail's // arg2->cell_array. Both products move to it directly; there is no // second, independently-computed cell array to disagree with it. var moved = new Vector3(42f, 12f, 50f); reg.CommitSetPosition( entityId, moved, Quaternion.Identity, seedCellId: CellB, worldOffsetX: OffX, worldOffsetY: OffY, action: PhysicsShadowCommitAction.Replace, crossCellIds: [CellB]); Assert.Equal(new[] { CellB }, reg.GetOwnerCells(entityId)); Assert.True(reg.TryGetRetailCellArray(entityId, out var after)); Assert.Equal(reg.GetOwnerCells(entityId), after); } [Fact] public void CommitSetPosition_KeepWhenEmpty_LeavesBothProductsUntouched() { var reg = new ShadowObjectRegistry(); const uint entityId = 0x19u; IReadOnlyList parts = new[] { Cyl(0x6201u) }; reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, parts, state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId, isStatic: false, partArray: parts); Assert.True(reg.TryGetRetailCellArray(entityId, out var before)); Assert.Equal(new[] { CellA }, before); // Retail's num_cells > 0 gate (pc:283534/pc:283540): an EMPTY // transition cell_array leaves the previous shadows exactly as they // were for BOTH products — no republish of either. reg.CommitSetPosition( entityId, Pos, Quaternion.Identity, seedCellId: CellA, worldOffsetX: OffX, worldOffsetY: OffY, action: PhysicsShadowCommitAction.Preserve, crossCellIds: ImmutableArray.Empty); Assert.Equal(new[] { CellA }, reg.GetOwnerCells(entityId)); Assert.True(reg.TryGetRetailCellArray(entityId, out var after)); Assert.Equal(new[] { CellA }, after); } // ------------------------------------------------------------------- // ReplaceMultiPartPayload is retail's SetPart: it swaps which parts // occupy the CURRENT CELLARRAY without re-flooding. // ------------------------------------------------------------------- [Fact] public void ReplaceMultiPartPayload_SwapsPartArrayWithoutReflooding() { var reg = new ShadowObjectRegistry(); const uint entityId = 0x17u; IReadOnlyList initial = new[] { Cyl(0x7001u) }; reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, initial, state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId, partArray: initial); Assert.True(reg.TryGetRetailCellArray(entityId, out var before)); Assert.Equal(new[] { CellA }, before); Assert.Equal(RetailCellArrayRoute.Cylsphere, reg.GetRetailCellArrayRoute(entityId)); IReadOnlyList replacement = new[] { Cyl(0x7002u), Cyl(0x7003u) }; reg.ReplaceMultiPartPayload(entityId, Pos, Quaternion.Identity, replacement, state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId, partArray: replacement); Assert.True(reg.TryGetRetailCellArray(entityId, out var after)); // No reflood: the CELLARRAY is unchanged (still CellA only). Assert.Equal(before, after); // The route stays whatever the LAST recompute (at registration) // decided; ReplaceMultiPartPayload does not re-run the dispatch. Assert.Equal(RetailCellArrayRoute.Cylsphere, reg.GetRetailCellArrayRoute(entityId)); var entries = reg.GetRetailPartEntriesInCell(CellA) .Where(e => e.EntityId == entityId) .OrderBy(e => e.PartIndex) .ToList(); Assert.Equal(2, entries.Count); Assert.Equal(0x7002u, entries[0].GfxObjId); Assert.Equal(0x7003u, entries[1].GfxObjId); Assert.DoesNotContain(entries, e => e.GfxObjId == 0x7001u); } // ------------------------------------------------------------------- // Campaign OVERHAUL S2 chunk 3: one flood per registration. Both // _entityToCells/_cells (collision) and the retail render product now // come from the SAME CELLARRAY when a caller supplies partArray — a // decorative non-BSP-colliding visual part widens COLLISION membership // too, not just the retail product. The BSP test still decides contact // at query time; only membership (which cells hold a row at all) widens. // ------------------------------------------------------------------- [Fact] public void RegisterMultiPart_DecorativePartArrayEntry_WidensCollisionRowsToo() { var reg = new ShadowObjectRegistry(); const uint entityId = 0x20u; const uint collidingGfxObj = 0xAAAA0001u; const uint decorativeGfxObj = 0xBBBB0001u; // The entity's actual COLLISION geometry is a single BSP part at the // entity's own origin (CellA only, on its own — AP-152's exclusive // collision dispatch). IReadOnlyList collisionShapes = new[] { Bsp(collidingGfxObj) }; // Its WHOLE VISUAL part array (chunk 1b's partArray input) additionally // carries a decorative part 30 m away with no collision of its own. // ShadowShapeBuilder.FromStaticRenderParts/FromSetupRenderParts tag // EVERY visual part CollisionType.BSP as a pure geometry carrier // regardless of whether its GfxObj has a real physics BSP, so this // mirrors production exactly. IReadOnlyList partArray = new[] { Bsp(collidingGfxObj), Bsp(decorativeGfxObj, new Vector3(30f, 0f, 0f)), }; reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, collisionShapes, state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId, partArray: partArray); // Retail CELLARRAY reaches both cells — the decorative part's box // pulls in CellB even though it cannot collide. Assert.True(reg.TryGetRetailCellArray(entityId, out var retailCells)); Assert.Equal(new[] { CellA, CellB }, retailCells); Assert.Equal(RetailCellArrayRoute.BoundingBox, reg.GetRetailCellArrayRoute(entityId)); // Chunk 3: the SAME array now drives collision. Retail == collision // by construction, not coincidence. Assert.Equal(retailCells, reg.GetOwnerCells(entityId)); foreach (uint cellId in new[] { CellA, CellB }) { var collisionRows = reg.GetObjectsInCell(cellId) .Where(e => e.EntityId == entityId) .ToList(); Assert.Single(collisionRows); Assert.Equal(collidingGfxObj, collisionRows[0].GfxObjId); // The decorative part never appears as a COLLISION row (AP-152: // the collision dispatch stays BSP-exclusive over collisionShapes) // — only as a retail render part entry. Assert.DoesNotContain(collisionRows, e => e.GfxObjId == decorativeGfxObj); var partEntries = reg.GetRetailPartEntriesInCell(cellId) .Where(e => e.EntityId == entityId) .Select(e => e.GfxObjId) .ToList(); Assert.Contains(collidingGfxObj, partEntries); Assert.Contains(decorativeGfxObj, partEntries); } } [Fact] public void RegisterMultiPart_WithoutDecorativeParts_RetailArrayStillEqualsCollisionCells() { // BSP-only shapes with partArray == shapes: retail array and // collision cells must agree exactly, whether or not a partArray is // supplied at all (item F: "registering with partArray and without // must produce identical _entityToCells for BSP-only shapes"). var withPartArray = new ShadowObjectRegistry(); var withoutPartArray = new ShadowObjectRegistry(); const uint entityId = 0x21u; IReadOnlyList shapes = new[] { Bsp(0x0100_0055u, radius: 2f) }; withPartArray.RegisterMultiPart(entityId, Pos, Quaternion.Identity, shapes, state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId, seedCellId: CellA, isStatic: true, partArray: shapes); withoutPartArray.RegisterMultiPart(entityId, Pos, Quaternion.Identity, shapes, state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId, seedCellId: CellA, isStatic: true); Assert.Equal( withoutPartArray.GetOwnerCells(entityId), withPartArray.GetOwnerCells(entityId)); Assert.True(withPartArray.TryGetRetailCellArray(entityId, out var retailCells)); Assert.Equal(withPartArray.GetOwnerCells(entityId), retailCells); } // ------------------------------------------------------------------- // Campaign OVERHAUL S2 chunk 3 item B: the staged // TryPrepareSetPosition/TryApplySetPosition pipeline must carry the // retail render product exactly like the direct CommitSetPosition path // — the chunk-1 gap where InstallOwnerState never seeded the staging // registry's retail dictionaries, so its own internal Contract-A // recompute silently no-opped and the applied commit dropped the retail // product on the floor. // ------------------------------------------------------------------- [Fact] public void StagedSetPosition_WithRetainedPartArray_MovesBothProductsTogether() { var reg = new ShadowObjectRegistry(); const uint entityId = 0x30u; IReadOnlyList parts = new[] { Cyl(0x8001u) }; reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, parts, state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId, partArray: parts); Assert.True(reg.TryGetRetailCellArray(entityId, out var before)); Assert.Equal(new[] { CellA }, before); Assert.Equal(new[] { CellA }, reg.GetOwnerCells(entityId)); var moved = new Vector3(42f, 12f, 50f); // 30 m away in X -> CellB Assert.True(reg.TryPrepareSetPosition( entityId, moved, Quaternion.Identity, seedCellId: CellB, worldOffsetX: OffX, worldOffsetY: OffY, PhysicsShadowCommitAction.Replace, crossCellIds: [CellB], provenShapeless: false, suspendOwner: false, out var prepared)); // Not yet applied — both products still describe the OLD position. Assert.Equal(new[] { CellA }, reg.GetOwnerCells(entityId)); Assert.True(reg.TryGetRetailCellArray(entityId, out var stillBefore)); Assert.Equal(new[] { CellA }, stillBefore); Assert.True(reg.TryApplySetPosition(prepared!, out var receipt)); Assert.True(receipt.Mutated); Assert.Equal(new[] { CellB }, reg.GetOwnerCells(entityId)); Assert.True(reg.TryGetRetailCellArray(entityId, out var after)); Assert.Equal(new[] { CellB }, after); Assert.Equal(RetailCellArrayRoute.Cylsphere, reg.GetRetailCellArrayRoute(entityId)); var partEntries = reg.GetRetailPartEntriesInCell(CellB) .Where(e => e.EntityId == entityId).ToList(); Assert.Single(partEntries); Assert.Equal(0x8001u, partEntries[0].GfxObjId); Assert.DoesNotContain( reg.GetRetailPartEntriesInCell(CellA), e => e.EntityId == entityId); var collisionRows = reg.GetObjectsInCell(CellB) .Where(e => e.EntityId == entityId).ToList(); Assert.Single(collisionRows); Assert.Equal(0x8001u, collisionRows[0].GfxObjId); Assert.DoesNotContain( reg.GetObjectsInCell(CellA), e => e.EntityId == entityId); } [Fact] public void StagedSetPosition_KeepWhenEmpty_PreservesBothProductsTogether() { var reg = new ShadowObjectRegistry(); const uint entityId = 0x31u; IReadOnlyList parts = new[] { Cyl(0x8101u) }; reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, parts, state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId, partArray: parts); Assert.True(reg.TryGetRetailCellArray(entityId, out var before)); Assert.Equal(new[] { CellA }, before); Assert.Equal(new[] { CellA }, reg.GetOwnerCells(entityId)); // seedCellId: 0 with the Recalculate action resolves NO seed at all // (landblockId derives from seedCellId, so it is also 0 — // DeriveOutdoorSeed's own guard) — retail keeps the previous // shadows (SetPositionInternal num_cells gate, pc:283540). The // staged pipeline must honor this for BOTH products, not just // collision. Assert.True(reg.TryPrepareSetPosition( entityId, new Vector3(999f, 999f, 50f), Quaternion.Identity, seedCellId: 0u, worldOffsetX: OffX, worldOffsetY: OffY, PhysicsShadowCommitAction.Recalculate, crossCellIds: ImmutableArray.Empty, provenShapeless: false, suspendOwner: false, out var prepared)); Assert.True(reg.TryApplySetPosition(prepared!, out _)); Assert.Equal(new[] { CellA }, reg.GetOwnerCells(entityId)); Assert.True(reg.TryGetRetailCellArray(entityId, out var after)); Assert.Equal(new[] { CellA }, after); Assert.Equal(RetailCellArrayRoute.Cylsphere, reg.GetRetailCellArrayRoute(entityId)); var partEntries = reg.GetRetailPartEntriesInCell(CellA) .Where(e => e.EntityId == entityId).ToList(); Assert.Single(partEntries); Assert.Equal(0x8101u, partEntries[0].GfxObjId); } // ------------------------------------------------------------------- // Campaign OVERHAUL S2 chunk 4: AttachChild/DetachChild — retail's // add_shadows_to_cells child-inheritance recursion (Contract B). An // attached projection (equipped weapon/shield/ammunition) owns no // independent collision shapes; it inherits the root's CELLARRAY and // gets PART ENTRIES only, never a collision row. // ------------------------------------------------------------------- private static IReadOnlyList ChildParts(params uint[] gfxObjIds) { var shapes = new ShadowShape[gfxObjIds.Length]; for (int i = 0; i < gfxObjIds.Length; i++) shapes[i] = Bsp(gfxObjIds[i]); return shapes; } [Fact] public void AttachChild_GivesChildTheRootsArrayAndEntries() { var reg = new ShadowObjectRegistry(); const uint rootId = 0x40u; const uint childId = 0x41u; IReadOnlyList rootParts = new[] { Cyl(0x9001u) }; reg.RegisterMultiPart(rootId, Pos, Quaternion.Identity, rootParts, state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId, partArray: rootParts); Assert.True(reg.TryGetRetailCellArray(rootId, out var rootCells)); Assert.Equal(new[] { CellA }, rootCells); IReadOnlyList childParts = ChildParts(0xC001u, 0xC002u); Assert.True(reg.AttachChild(childId, rootId, childParts)); Assert.True(reg.TryGetRetailCellArray(childId, out var childCells)); Assert.Equal(rootCells, childCells); var entries = reg.GetRetailPartEntriesInCell(CellA) .Where(e => e.EntityId == childId) .OrderBy(e => e.PartIndex) .ToList(); Assert.Equal(2, entries.Count); Assert.Equal(0xC001u, entries[0].GfxObjId); Assert.Equal(0xC002u, entries[1].GfxObjId); // Never a collision row — acdream's attached projections own none. Assert.Empty(reg.GetOwnerCells(childId)); Assert.DoesNotContain( reg.GetObjectsInCell(CellA), e => e.EntityId == childId); } [Fact] public void AttachChild_RootMove_RepublishesTheChild() { var reg = new ShadowObjectRegistry(); const uint rootId = 0x42u; const uint childId = 0x43u; IReadOnlyList rootParts = new[] { Cyl(0x9101u) }; reg.RegisterMultiPart(rootId, Pos, Quaternion.Identity, rootParts, state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId, isStatic: false, partArray: rootParts); Assert.True(reg.AttachChild(childId, rootId, ChildParts(0xC101u))); Assert.True(reg.TryGetRetailCellArray(childId, out var before)); Assert.Equal(new[] { CellA }, before); var moved = new Vector3(42f, 12f, 50f); // -> CellB reg.CommitSetPosition( rootId, moved, Quaternion.Identity, seedCellId: CellB, worldOffsetX: OffX, worldOffsetY: OffY, action: PhysicsShadowCommitAction.Replace, crossCellIds: [CellB]); Assert.True(reg.TryGetRetailCellArray(rootId, out var rootAfter)); Assert.Equal(new[] { CellB }, rootAfter); Assert.True(reg.TryGetRetailCellArray(childId, out var childAfter)); Assert.Equal(rootAfter, childAfter); Assert.DoesNotContain( reg.GetRetailPartEntriesInCell(CellA), e => e.EntityId == childId); var movedEntries = reg.GetRetailPartEntriesInCell(CellB) .Where(e => e.EntityId == childId).ToList(); Assert.Single(movedEntries); Assert.Equal(0xC101u, movedEntries[0].GfxObjId); } [Fact] public void DetachChild_ClearsTheChildsProducts() { var reg = new ShadowObjectRegistry(); const uint rootId = 0x44u; const uint childId = 0x45u; IReadOnlyList rootParts = new[] { Cyl(0x9201u) }; reg.RegisterMultiPart(rootId, Pos, Quaternion.Identity, rootParts, state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId, partArray: rootParts); Assert.True(reg.AttachChild(childId, rootId, ChildParts(0xC201u))); Assert.True(reg.TryGetRetailCellArray(childId, out _)); Assert.True(reg.DetachChild(childId)); Assert.False(reg.TryGetRetailCellArray(childId, out var afterCells)); Assert.Empty(afterCells); Assert.DoesNotContain( reg.GetRetailPartEntriesInCell(CellA), e => e.EntityId == childId); // The root itself is untouched. Assert.True(reg.TryGetRetailCellArray(rootId, out var rootCells)); Assert.Equal(new[] { CellA }, rootCells); // A second detach is a no-op, not an error. Assert.False(reg.DetachChild(childId)); } [Fact] public void Deregister_RootCascadesDetachOfAttachedChildren() { var reg = new ShadowObjectRegistry(); const uint rootId = 0x46u; const uint childId = 0x47u; IReadOnlyList rootParts = new[] { Cyl(0x9301u) }; reg.RegisterMultiPart(rootId, Pos, Quaternion.Identity, rootParts, state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId, partArray: rootParts); Assert.True(reg.AttachChild(childId, rootId, ChildParts(0xC301u))); reg.Deregister(rootId); Assert.False(reg.TryGetRetailCellArray(rootId, out _)); Assert.False(reg.TryGetRetailCellArray(childId, out var childCells)); Assert.Empty(childCells); Assert.DoesNotContain( reg.GetRetailPartEntriesInCell(CellA), e => e.EntityId == childId); } [Fact] public void AttachChild_NestedChildOfAChild_ResolvesToTheRoot() { var reg = new ShadowObjectRegistry(); const uint rootId = 0x48u; const uint directChildId = 0x49u; const uint grandchildId = 0x4Au; IReadOnlyList rootParts = new[] { Cyl(0x9401u) }; reg.RegisterMultiPart(rootId, Pos, Quaternion.Identity, rootParts, state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId, partArray: rootParts); Assert.True(reg.AttachChild(directChildId, rootId, ChildParts(0xC401u))); // Attach the grandchild to the DIRECT CHILD, not the root — nested // attachment must still resolve to the ultimate root's array. Assert.True(reg.AttachChild(grandchildId, directChildId, ChildParts(0xC402u))); Assert.True(reg.TryGetRetailCellArray(rootId, out var rootCells)); Assert.True(reg.TryGetRetailCellArray(grandchildId, out var grandchildCells)); Assert.Equal(rootCells, grandchildCells); var entries = reg.GetRetailPartEntriesInCell(CellA) .Where(e => e.EntityId == grandchildId).ToList(); Assert.Single(entries); Assert.Equal(0xC402u, entries[0].GfxObjId); // Moving the root republishes BOTH the direct child and the // grandchild, in child-list order. var moved = new Vector3(42f, 12f, 50f); reg.CommitSetPosition( rootId, moved, Quaternion.Identity, seedCellId: CellB, worldOffsetX: OffX, worldOffsetY: OffY, action: PhysicsShadowCommitAction.Replace, crossCellIds: [CellB]); Assert.True(reg.TryGetRetailCellArray(grandchildId, out var grandchildAfter)); Assert.Equal(new[] { CellB }, grandchildAfter); // Detaching the direct child cascades to the grandchild too. Assert.True(reg.DetachChild(directChildId)); Assert.False(reg.TryGetRetailCellArray(grandchildId, out _)); } [Fact] public void AttachChild_SelfAttachRejected() { var reg = new ShadowObjectRegistry(); const uint entityId = 0x4Bu; IReadOnlyList rootParts = new[] { Cyl(0x9501u) }; reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, rootParts, state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId, partArray: rootParts); Assert.False(reg.AttachChild(entityId, entityId, ChildParts(0xC501u))); } [Fact] public void AttachChild_CycleRejected_RootUnaffected() { var reg = new ShadowObjectRegistry(); const uint rootId = 0x4Cu; const uint childId = 0x4Du; IReadOnlyList rootParts = new[] { Cyl(0x9601u) }; reg.RegisterMultiPart(rootId, Pos, Quaternion.Identity, rootParts, state: 0u, flags: EntityCollisionFlags.None, OffX, OffY, LbId, partArray: rootParts); Assert.True(reg.AttachChild(childId, rootId, ChildParts(0xC601u))); Assert.True(reg.TryGetRetailCellArray(rootId, out var rootCellsBefore)); // Attaching the root TO its own child would form a cycle. Assert.False(reg.AttachChild(rootId, childId, ChildParts(0xC602u))); Assert.True(reg.TryGetRetailCellArray(rootId, out var rootCellsAfter)); Assert.Equal(rootCellsBefore, rootCellsAfter); Assert.Equal(new[] { CellA }, reg.GetOwnerCells(rootId)); // The child's own attachment is unaffected by the rejected call. 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 partArray = new[] { Bsp(0x0100_0060u, radius: 2f) }; reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, Array.Empty(), 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 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(), 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 partArray = new[] { Bsp(0x0100_0062u, radius: 2f) }; reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, Array.Empty(), 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(), 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 partArray = new[] { Bsp(0x0100_0063u, radius: 2f) }; reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, Array.Empty(), 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 partArray = new[] { Bsp(0x0100_0064u, radius: 2f) }; reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, Array.Empty(), 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 collision = new[] { Bsp(0x0100_0070u, radius: 2f) }; IReadOnlyList 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 parts = new[] { Bsp(0x0100_0072u, radius: 2f) }; reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, Array.Empty(), 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 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.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 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 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 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 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(), 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 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 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()); // 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 parts = new[] { Bsp(0x0100_0089u, radius: 1f) }; reg.RegisterMultiPart(entityId, Pos, Quaternion.Identity, Array.Empty(), 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 — retail's own zero-sphere find_cell_list // (0x0052b4e0: add the current cell, skip the transit walk). reg.CommitSetPosition( entityId, new Vector3(42f, 12f, 50f), Quaternion.Identity, seedCellId: CellB, worldOffsetX: OffX, worldOffsetY: OffY, action: PhysicsShadowCommitAction.None, crossCellIds: ImmutableArray.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)); } }