diff --git a/src/AcDream.Core/Physics/CollisionWorldState.cs b/src/AcDream.Core/Physics/CollisionWorldState.cs index 3e46a97f..6dac2a2a 100644 --- a/src/AcDream.Core/Physics/CollisionWorldState.cs +++ b/src/AcDream.Core/Physics/CollisionWorldState.cs @@ -114,6 +114,19 @@ internal sealed class CollisionWorldState internal Dictionary ShadowOwnerIndices { get; } = new(); internal Stack ShadowOwnerFreeSlots { get; } = new(); + // ── Campaign OVERHAUL S2 chunk 1 ──────────────────────────────────────── + // Retail's whole-part-array CELLARRAY (CPhysicsObj::calc_cross_cells_static + // 0x00515160) and its sibling per-cell part-entry product + // (CPartArray::AddPartsShadow 0x00517e40), retained beside the existing + // ShadowEntity* fields. A NEW product only: nothing above touches it, + // and nothing in production reads it yet (see ShadowObjectRegistry). + internal Dictionary> + ShadowEntityRetailPartArrays { get; } = new(); + internal Dictionary> ShadowEntityRetailCellArrays { get; } = new(); + internal Dictionary + ShadowEntityRetailCellArrayRoutes { get; } = new(); + internal Dictionary> RetailPartEntriesByCell { get; } = new(); + // ── O1 per-prefix installed-key ledgers ──────────────────────────────── // Every mutation of the five landblock-scoped world maps goes through the // typed helpers below so these ledgers stay exact. The seal's landblock- diff --git a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs index 943753e2..5883e4dd 100644 --- a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs +++ b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs @@ -49,6 +49,46 @@ public sealed class ShadowObjectRegistry private Dictionary> _entityShapes => _collisionWorld.Current.ShadowEntityShapes; + /// + /// Campaign OVERHAUL S2 chunk 1: the caller-supplied WHOLE visual part + /// array behind and + /// — retail's + /// CPartArray.parts/num_parts input to + /// calc_cross_cells_static (0x00515160). Distinct from + /// , which is the BSP-only COLLISION dispatch + /// (AP-152): retail's CPartArray::AddPartsShadow (0x00517e40) + /// registers EVERY visual part, colliding or not, so this list is + /// typically wider. Retained so and + /// can recompute the retail products at + /// the new position without a caller re-supplying the part array on + /// every move. + /// + private Dictionary> _entityRetailPartArrays => + _collisionWorld.Current.ShadowEntityRetailPartArrays; + + /// + /// Retail's ordered CELLARRAY (Contract A) computed from + /// . A retained SIDE PRODUCT ONLY — + /// distinct from , which stays the existing + /// collision-flood answer until S2 chunk 3 cuts collision over. + /// + private Dictionary> _retailCellArrays => + _collisionWorld.Current.ShadowEntityRetailCellArrays; + + /// Which calc_cross_cells_static branch produced the + /// entry in (or the empty result). + private Dictionary _retailCellArrayRoutes => + _collisionWorld.Current.ShadowEntityRetailCellArrayRoutes; + + /// + /// Retail's per-cell CPartArray::AddPartsShadow (0x00517e40) + /// product: every crossed cell's part list, in CELLARRAY-then-part-array + /// order. Parallel to but keyed by the SAME cell ids + /// as , not . + /// + private Dictionary> _retailPartEntriesByCell => + _collisionWorld.Current.RetailPartEntriesByCell; + /// /// BR-7: per-entity registration arguments, kept so a registration can be /// RE-RUN when more cells hydrate. Retail's equivalent is @@ -367,6 +407,14 @@ public sealed class ShadowObjectRegistry /// them in separate cell lists (shadow_part_list versus /// shadow_object_list). Decorative meshes therefore need this path /// even when is empty. + /// + /// + /// Campaign OVERHAUL S2 chunk 1: RecomputeRetailCellArray's bbox + /// route is this same primitive, called from the entity's own + /// registration transaction instead of a per-frame consumer. This + /// method is that recomputation's predecessor; S2 chunk 2 deletes it + /// once consumers cut over to . + /// /// public IReadOnlyList ComputeStaticRenderCells( uint seedCellId, @@ -393,6 +441,259 @@ public sealed class ShadowObjectRegistry isStatic: true); } + /// + /// Retail's exact CObjCell::find_cell_list/find_bbox_cell_list + /// CELLARRAY (Contract A, re-verified 2026-09-01 through the live Ghidra + /// bridge at 127.0.0.1:8081 against patchmem.gpr) for one + /// entity's WHOLE part array, plus the sibling + /// CPartArray::AddPartsShadow (0x00517e40) per-cell part-entry + /// product, published into . + /// + /// + /// A CHUNK-1 SIDE PRODUCT ONLY (Campaign OVERHAUL S2 chunk 1, + /// `docs/research/2026-09-01-overhaul/s2-membership-ownership-map.md` + /// §3): it never writes or + /// , and nothing in production reads it yet. + /// + /// + /// + /// Route — CPhysicsObj::calc_cross_cells_static 0x00515160's + /// branch table: + /// + /// + /// cylsphere (CObjCell::find_cell_list 0x0052b9f0, over the + /// authored CylSpheres, reusing — "today's + /// cylinder registration") when (state & 0x10000) == 0 AND the + /// object's authored COLLISION dispatch (, + /// retail CPartArray::GetNumCylsphere() != 0: the Setup's + /// CylSpheres) carries at least one + /// shape. The visual part array + /// never decides the route: retail's cylspheres are Setup collision data, + /// not parts; + /// otherwise the bbox route (CPhysicsObj::find_bbox_cell_list + /// 0x00510fc0 via ) — + /// every other case, including a BSP-bearing object (state bit set) and a + /// part array with no authored CylSpheres at all. This is the exact + /// primitive already uses. + /// + /// + private void RecomputeRetailCellArray( + uint entityId, + uint seedCellId, + Vector3 entityWorldPos, + Quaternion entityWorldRot, + uint state, + IReadOnlyList collisionShapes, + IReadOnlyList partArray, + bool isStatic) + { + ClearRetailCellArray(entityId); + if (partArray.Count == 0 || seedCellId == 0u) + return; + + _entityRetailPartArrays[entityId] = partArray; + + // Contract A: GetNumCylsphere() reads the Setup's authored CylSpheres, + // which acdream carries as the Cylinder shapes of the COLLISION + // dispatch — never as visual parts. + bool hasCylsphere = false; + for (int i = 0; i < collisionShapes.Count; i++) + { + if (collisionShapes[i].CollisionType == ShadowCollisionType.Cylinder) + { + hasCylsphere = true; + break; + } + } + bool cylsphereRoute = (state & 0x10000u) == 0u && hasCylsphere; + + IReadOnlyList cellArray; + RetailCellArrayRoute route; + if (cylsphereRoute) + { + route = RetailCellArrayRoute.Cylsphere; + List cylSpheres = + BuildFloodSpheres(entityWorldPos, entityWorldRot, collisionShapes); + cellArray = CellTransit.BuildShadowCellSet( + FloodCache, seedCellId, cylSpheres, cylSpheres.Count, isStatic); + } + else + { + route = RetailCellArrayRoute.BoundingBox; + List boxes = + BuildFloodPartBoxes(entityWorldPos, entityWorldRot, partArray); + List spheres = + BuildBspPartSpheres(entityWorldPos, entityWorldRot, partArray); + cellArray = CellTransit.BuildShadowCellSetFromParts( + FloodCache, seedCellId, boxes, spheres, isStatic); + } + + // The route is recorded even when the flood came back empty — the + // dispatch decision was still made (mirrors GetRetailCellArrayRoute + // being independently observable from cell-list non-emptiness). + _retailCellArrayRoutes[entityId] = route; + if (cellArray.Count == 0) + return; + + var orderedCells = new List(cellArray.Count); + for (int i = 0; i < cellArray.Count; i++) + orderedCells.Add(cellArray[i]); + _retailCellArrays[entityId] = orderedCells; + PublishRetailPartEntries(entityId, orderedCells, partArray); + } + + /// + /// Recomputes at a new position + /// when — and only when — a retail part array is currently retained for + /// . No-op for every entity registered + /// without one (item E of the S2 chunk-1 contract: existing callers stay + /// byte-for-byte unaffected). + /// + private void RecomputeRetailCellArrayIfPresent( + uint entityId, + uint seedCellId, + Vector3 worldPosition, + Quaternion worldRotation, + uint state, + bool isStatic) + { + if (!_entityRetailPartArrays.TryGetValue( + entityId, + out IReadOnlyList? partArray) + || partArray.Count == 0) + { + return; + } + // The retained multipart collision dispatch decides the route; a + // single-shape registration retains none, which can only be a + // non-cylsphere object here (cylinder singles never move). + IReadOnlyList collisionShapes = + _entityShapes.TryGetValue(entityId, out var retainedShapes) + ? retainedShapes + : Array.Empty(); + RecomputeRetailCellArray( + entityId, seedCellId, worldPosition, worldRotation, state, + collisionShapes, partArray, isStatic); + } + + /// + /// Removes every retained retail-cell-array product for + /// : the part array, the route, the ordered + /// CELLARRAY, and every per-cell row it + /// published. The inverse of , + /// mirroring retail's remove_shadows_from_cells (0x00511230) + /// symmetry for this side product. + /// + private void ClearRetailCellArray(uint entityId) + { + if (_retailCellArrays.TryGetValue(entityId, out List? cells)) + { + RemoveRetailPartEntriesFromCells(entityId, cells); + _retailCellArrays.Remove(entityId); + } + _retailCellArrayRoutes.Remove(entityId); + _entityRetailPartArrays.Remove(entityId); + } + + /// Removes every owned by + /// from each of , + /// reclaiming a cell's list once it is left empty. + private void RemoveRetailPartEntriesFromCells( + uint entityId, + List cellIds) + { + for (int i = 0; i < cellIds.Count; i++) + { + if (_retailPartEntriesByCell.TryGetValue( + cellIds[i], + out List? entries)) + { + entries.RemoveAll(e => e.EntityId == entityId); + if (entries.Count == 0) + _retailPartEntriesByCell.Remove(cellIds[i]); + } + } + } + + /// + /// Publishes retail's CPartArray::AddPartsShadow (0x00517e40) rows + /// for one entity: for every cell in , in + /// array order, for every part in order, one + /// — the exact retail insertion order. + /// ClipPlanesRequired mirrors num_shadow_objects > 1 ? + /// cell->clip_planes : null. + /// + private void PublishRetailPartEntries( + uint entityId, + List orderedCells, + IReadOnlyList partArray) + { + bool clipPlanesRequired = orderedCells.Count > 1; + for (int cellIndex = 0; cellIndex < orderedCells.Count; cellIndex++) + { + uint cellId = orderedCells[cellIndex]; + if (!_retailPartEntriesByCell.TryGetValue( + cellId, + out List? entries)) + { + entries = new List(); + _retailPartEntriesByCell[cellId] = entries; + } + for (int partIndex = 0; partIndex < partArray.Count; partIndex++) + { + entries.Add(new RetailPartEntry( + entityId, + partIndex, + partArray[partIndex].GfxObjId, + cellId, + clipPlanesRequired)); + } + } + } + + /// + /// The retail CELLARRAY retained for — see + /// . Returns + /// when no retail part array was ever supplied for this entity (every + /// existing caller that omits the new trailing partArray parameter + /// on /) or when the + /// last computed CELLARRAY came back empty. + /// + public bool TryGetRetailCellArray( + uint entityId, + out IReadOnlyList cells) + { + if (_retailCellArrays.TryGetValue(entityId, out List? list)) + { + cells = list; + return true; + } + cells = Array.Empty(); + return false; + } + + /// + /// Retail's CPartArray::AddPartsShadow (0x00517e40) part list for + /// one cell, across every entity that crossed it — CELLARRAY-then-part + /// order per entity, registration order across entities. Empty when no + /// entity's retail part array reaches this cell. + /// + public IReadOnlyList GetRetailPartEntriesInCell(uint cellId) => + _retailPartEntriesByCell.TryGetValue(cellId, out List? entries) + ? entries + : Array.Empty(); + + /// + /// Which calc_cross_cells_static branch (Contract A) produced + /// 's retail CELLARRAY, or + /// when no retail part array is + /// retained for it. + /// + public RetailCellArrayRoute GetRetailCellArrayRoute(uint entityId) => + _retailCellArrayRoutes.TryGetValue(entityId, out RetailCellArrayRoute route) + ? route + : RetailCellArrayRoute.None; + /// /// Register a single-shape entity. is the /// entity's m_position.objcell_id — the flood seed. Pass 0 to @@ -415,7 +716,8 @@ public sealed class ShadowObjectRegistry EntityCollisionFlags flags = EntityCollisionFlags.None, uint seedCellId = 0u, bool isStatic = true, - bool publishMutation = true) + bool publishMutation = true, + IReadOnlyList? partArray = null) { // Flood FIRST: retail keeps the previous shadows when the new cell // array would be empty (SetPositionInternal num_cells gate, @@ -453,6 +755,22 @@ public sealed class ShadowObjectRegistry BumpOwnerVersion(entityId); else RefreshOwnerPrefixIndex(entityId); + + // Campaign OVERHAUL S2 chunk 1: an untouched side product when no + // caller supplies a part array (every call site before chunk 1b). + if (partArray is not null) + { + IReadOnlyList collisionShapes = + collisionType == ShadowCollisionType.Cylinder + ? new[] + { + ShadowShape.Cylinder( + gfxObjId, Vector3.Zero, Quaternion.Identity, scale, radius, cylHeight), + } + : Array.Empty(); + RecomputeRetailCellArray( + entityId, seed, worldPos, rotation, state, collisionShapes, partArray, isStatic); + } } /// @@ -500,7 +818,8 @@ public sealed class ShadowObjectRegistry float worldOffsetX, float worldOffsetY, uint landblockId, uint seedCellId = 0u, bool isStatic = false, - bool publishMutation = true) + bool publishMutation = true, + IReadOnlyList? partArray = null) { if (shapes.Count == 0) { Deregister(entityId); return; } @@ -581,6 +900,17 @@ public sealed class ShadowObjectRegistry BumpOwnerVersion(entityId); else RefreshOwnerPrefixIndex(entityId); + + // Campaign OVERHAUL S2 chunk 1: an untouched side product when no + // caller supplies a part array (every call site before chunk 1b). + // Deliberately independent of `shapes`/hasBsp above: retail's + // per-part render membership (AddPartsShadow) walks EVERY visual + // part, not just the BSP-only collision dispatch AP-152 emits. + if (partArray is not null) + { + RecomputeRetailCellArray( + entityId, seed, entityWorldPos, entityWorldRot, state, shapes, partArray, isStatic); + } } /// @@ -603,7 +933,8 @@ public sealed class ShadowObjectRegistry uint landblockId, uint seedCellId = 0u, bool isStatic = false, - bool suspendIfNew = false) + bool suspendIfNew = false, + IReadOnlyList? partArray = null) { if (!_entityReg.TryGetValue(entityId, out RegistrationRecord? prior) || !prior.IsMultiPart) @@ -621,7 +952,8 @@ public sealed class ShadowObjectRegistry worldOffsetY, landblockId, seedCellId, - isStatic); + isStatic, + partArray: partArray); if (suspendIfNew) Suspend(entityId); return; @@ -637,6 +969,21 @@ public sealed class ShadowObjectRegistry Flags = flags, }; + // Campaign OVERHAUL S2 chunk 1: SetPart semantics apply here too — + // this replaces the retained part array WITHOUT re-flooding. It + // reuses the CURRENT retail CELLARRAY (if any) and only rewrites + // which parts occupy it, mirroring the collision payload swap below. + if (partArray is not null) + { + _entityRetailPartArrays[entityId] = partArray; + if (_retailCellArrays.TryGetValue(entityId, out List? retailCells) + && retailCells.Count != 0) + { + RemoveRetailPartEntriesFromCells(entityId, retailCells); + PublishRetailPartEntries(entityId, retailCells, partArray); + } + } + if (suspended || !_entityToCells.TryGetValue(entityId, out List? cells)) { BumpOwnerVersion(entityId); @@ -891,18 +1238,28 @@ public sealed class ShadowObjectRegistry && DeriveOutdoorSeed(worldPos, worldOffsetX, worldOffsetY, landblockId) == 0u) return; + // Campaign OVERHAUL S2 chunk 1: forward the retained retail part + // array (if any) so the flood below recomputes it at the new + // seed/position, exactly as the existing collision flood does. Not + // retained for this entity → null → both calls stay pixel-for-pixel + // what they were before chunk 1. + _entityRetailPartArrays.TryGetValue( + entityId, + out IReadOnlyList? retainedPartArray); + if (reg.IsMultiPart && _entityShapes.TryGetValue(entityId, out var shapes)) { RegisterMultiPart(entityId, worldPos, rotation, shapes, reg.State, reg.Flags, worldOffsetX, worldOffsetY, landblockId, - seedCellId, reg.IsStatic); + seedCellId, reg.IsStatic, partArray: retainedPartArray); return; } Register(entityId, reg.GfxObjId, worldPos, rotation, reg.Radius, worldOffsetX, worldOffsetY, landblockId, reg.CollisionType, reg.CylHeight, reg.Scale, - reg.State, reg.Flags, seedCellId, reg.IsStatic); + reg.State, reg.Flags, seedCellId, reg.IsStatic, + partArray: retainedPartArray); } /// @@ -1553,6 +1910,9 @@ public sealed class ShadowObjectRegistry EntityWorldPos = worldPosition, EntityWorldRot = worldRotation, }; + RecomputeRetailCellArrayIfPresent( + entityId, seedCellId, worldPosition, worldRotation, + registration.State, registration.IsStatic); BumpOwnerVersion(entityId); } @@ -1655,6 +2015,9 @@ public sealed class ShadowObjectRegistry if (withdrawn.Count == 0) _withdrawnPrefixesByOwner.Remove(entityId); } + RecomputeRetailCellArrayIfPresent( + entityId, seedCellId, worldPosition, worldRotation, + registration.State, registration.IsStatic); BumpOwnerVersion(entityId); } @@ -1976,6 +2339,11 @@ public sealed class ShadowObjectRegistry _suspendedEntities.Remove(entityId); _suspendedEntityCells.Remove(entityId); _withdrawnPrefixesByOwner.Remove(entityId); + // Campaign OVERHAUL S2 chunk 1: the retail cell-array side product + // is torn down symmetrically with every other per-entity row, both + // for a genuine Deregister and for the "clear then re-register" + // idiom Register/RegisterMultiPart use ahead of their own flood. + ClearRetailCellArray(entityId); if (existed && publishMutation) { BumpOwnerVersion(entityId); @@ -2901,3 +3269,48 @@ public readonly record struct ShadowEntry( // the shape sitting at the entity's origin. Vector3 LocalPosition = default, Quaternion LocalRotation = default); + +/// +/// Campaign OVERHAUL S2 chunk 1: which branch of retail's +/// CPhysicsObj::calc_cross_cells_static (0x00515160) produced one +/// entity's result. +/// +public enum RetailCellArrayRoute +{ + /// No retail part array is retained for this entity. + None, + + /// + /// CObjCell::find_cell_list (0x0052b9f0) over the object's + /// authored CylSpheres — taken when the cached + /// HAS_PHYSICS_BSP_PS state bit (0x10000) is clear AND the part + /// array carries at least one Cylinder shape. + /// + Cylsphere, + + /// + /// CPhysicsObj::find_bbox_cell_list (0x00510fc0) over the whole + /// part array — every other case, including a BSP-bearing object (state + /// bit set) and a part array with no authored CylSpheres. + /// + BoundingBox, +} + +/// +/// One row of retail's per-cell CPartArray::AddPartsShadow +/// (0x00517e40) render-shadow product: part of +/// entity 's whole part array, registered into +/// — one member of that entity's retail CELLARRAY +/// (). +/// mirrors retail's +/// num_shadow_objects > 1 ? cell->clip_planes : null selection — +/// true exactly when the owning entity's CELLARRAY has more than one member. +/// Entries for one cell are retained in CELLARRAY-then-part-array order, +/// matching retail's insertion order. +/// +public readonly record struct RetailPartEntry( + uint EntityId, + int PartIndex, + uint GfxObjId, + uint CellId, + bool ClipPlanesRequired); diff --git a/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryRetailCellArrayTests.cs b/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryRetailCellArrayTests.cs new file mode 100644 index 00000000..2cd0a189 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryRetailCellArrayTests.cs @@ -0,0 +1,356 @@ +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 is ComputeStaticRenderCells's own primitive, and + // supplying a part array never touches the existing collision product. + // ------------------------------------------------------------------- + + [Fact] + public void RegisterMultiPart_WithPartArray_BoundingBoxRouteMatchesComputeStaticRenderCells() + { + 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)); + + IReadOnlyList expected = + withPartArray.ComputeStaticRenderCells(CellA, Pos, Quaternion.Identity, parts); + Assert.Equal(expected, retailCells); + Assert.NotEmpty(expected); // the fixture must actually exercise a flood + + // 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); + } + + [Fact] + public void CommitSetPosition_WithRetainedPartArray_RecomputesRetailCellArray() + { + 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); + + var moved = new Vector3(42f, 12f, 50f); // -> CellB + reg.CommitSetPosition( + entityId, + moved, + Quaternion.Identity, + seedCellId: CellB, + worldOffsetX: OffX, + worldOffsetY: OffY, + action: PhysicsShadowCommitAction.None, + crossCellIds: ImmutableArray.Empty); + + Assert.True(reg.TryGetRetailCellArray(entityId, out var after)); + Assert.Equal(new[] { CellB }, 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); + } +}