diff --git a/src/AcDream.Core/Physics/CellTransit.cs b/src/AcDream.Core/Physics/CellTransit.cs
index 0b307182..949ca16c 100644
--- a/src/AcDream.Core/Physics/CellTransit.cs
+++ b/src/AcDream.Core/Physics/CellTransit.cs
@@ -441,6 +441,155 @@ public static class CellTransit
}
}
+ ///
+ /// BR-7 / A6.P4 (2026-06-11). Registration-side cell-set builder — the
+ /// sphere-overlap portal flood retail runs at SHADOW REGISTRATION time.
+ /// Verbatim port of CObjCell::find_cell_list (Ghidra 0x0052b4e0,
+ /// pc:308742) as invoked by CPhysicsObj::calc_cross_cells /
+ /// calc_cross_cells_static (Ghidra 0x00515230 / 0x00515160):
+ /// the seed + growing-array walk, WITHOUT the containing-cell pick
+ /// (registration passes a null out-cell).
+ ///
+ /// Shape, all Ghidra-verified (wf1-interior-collision.md):
+ ///
+ /// - Seed: indoor id (low16 ≥ 0x100) → exactly that one cell
+ /// (0052b563), added EVEN IF unloaded (retail add_cell()s the id
+ /// with a null pointer); outdoor id → the block-crossing
+ ///
+ /// (0052b53f).
+ /// - Growing-array walk (0052b576-0052b5ab), gated on the seed
+ /// being LOADED: each array cell's find_transit_cells
+ /// (vtable+0x80). Indoor cells →
+ ///
+ /// (sphere-vs-neighbor-BSP gates; exterior straddle → outside
+ /// cells, once per walk like retail's CELLARRAY.added_outside).
+ /// Outdoor cells → CLandCell::find_transit_cells
+ /// (0x00533800) = add_all_outside_cells (same once-guard) +
+ /// the building bridge CSortCell → CBuildingObj →
+ /// CEnvCell::check_building_transit
+ /// (0x00534060/0x006b5230/0x0052c5d0) — how an outdoor-seeded
+ /// door reaches the vestibule's shadow list. Unloaded indoor
+ /// cells in the array are not walked (0052b58e null check).
+ /// - Static prune (, retail
+ /// do_not_load_cells, 0052b66e): when the seed is indoor,
+ /// flood results are pruned to {seed} ∪ seed.stab_list
+ /// () — placement of
+ /// statics must not force-load cells. Note this also strips
+ /// outdoor cells (they are never in an EnvCell stab list):
+ /// retail interior statics never shadow into landcells; an
+ /// outdoor sphere reaches them via its OWN array's
+ /// check_building_transit instead.
+ ///
+ ///
+ /// Flood spheres: the object's REAL collision footprint — retail
+ /// globalizes the CylSpheres (low_pt → world, cyl radius, cap 10;
+ /// overload 0x0052b9f0), falling back to the part-array sorting sphere.
+ /// Callers map ShadowShape lists via
+ /// -derived helpers (see
+ /// ShadowObjectRegistry).
+ ///
+ public static IReadOnlyList BuildShadowCellSet(
+ PhysicsDataCache cache,
+ uint seedCellId,
+ IReadOnlyList worldSpheres,
+ int numSpheres,
+ bool isStatic)
+ {
+ var candidates = new CellArray();
+ int sphereCount = EffectiveSphereCount(worldSpheres, numSpheres);
+ if (seedCellId == 0 || sphereCount == 0)
+ return candidates.OrderedIds;
+
+ uint seedLow = seedCellId & 0xFFFFu;
+ // #106 frame convention: LandDefs lcoord math runs block-local;
+ // TryGetTerrainOrigin supplies the seed block's world origin
+ // (Zero fallback = legacy anchor-frame, same as the transit path).
+ cache.CellGraph.TryGetTerrainOrigin(seedCellId, out var blockOrigin);
+
+ bool seedLoaded;
+ if (seedLow >= 0x0100u)
+ {
+ candidates.Add(seedCellId);
+ // Retail adds the unloaded seed by id (null cell pointer) and
+ // skips the walk (gate on arg4 != 0 at 0052b576) — the object
+ // stays registered under just its claimed cell until the cell
+ // hydrates and the registration is re-run (CObjCell::init_objects
+ // → recalc_cross_cells, Ghidra 0x0052b420/0x00515a30; our
+ // equivalent is ShadowObjectRegistry's re-flood hook).
+ seedLoaded = cache.GetCellStruct(seedCellId) is not null;
+ }
+ else
+ {
+ AddAllOutsideCells(worldSpheres, sphereCount, seedCellId, blockOrigin, candidates);
+ // Outdoor seeds always walk: retail's null-CLandCell case is
+ // "landblock not loaded at all", where our per-cell building
+ // lookups below come back null anyway (documented adaptation).
+ seedLoaded = true;
+ }
+
+ if (seedLoaded)
+ {
+ bool outdoorAdded = seedLow < 0x0100u; // retail CELLARRAY.added_outside
+ for (int i = 0; i < candidates.Count; i++)
+ {
+ uint cellId = candidates.OrderedIds[i];
+ if ((cellId & 0xFFFFu) >= 0x0100u)
+ {
+ var cell = cache.GetCellStruct(cellId);
+ if (cell is null) continue;
+
+ FindTransitCellsSphere(
+ cache, cell, cellId, worldSpheres, sphereCount,
+ candidates, out bool exitStraddle, out _);
+
+ if (exitStraddle && !outdoorAdded)
+ {
+ AddAllOutsideCells(worldSpheres, sphereCount, seedCellId, blockOrigin, candidates);
+ outdoorAdded = true;
+ }
+ }
+ else
+ {
+ // CLandCell::find_transit_cells (0x00533800):
+ // add_all_outside_cells (added_outside-guarded) then the
+ // building bridge for the landcell's building, if any.
+ if (!outdoorAdded)
+ {
+ AddAllOutsideCells(worldSpheres, sphereCount, seedCellId, blockOrigin, candidates);
+ outdoorAdded = true;
+ }
+
+ var building = cache.GetBuilding(cellId);
+ if (building is not null)
+ CheckBuildingTransit(cache, building, worldSpheres, sphereCount, candidates, out _);
+ }
+ }
+
+ // Static prune (do_not_load_cells, 0052b66e): indoor-seeded
+ // statics keep only {seed} ∪ seed.stab_list.
+ if (isStatic && seedLow >= 0x0100u)
+ {
+ var seedCell = cache.GetCellStruct(seedCellId);
+ if (seedCell is not null)
+ {
+ var keep = new List(candidates.Count);
+ foreach (uint id in candidates.OrderedIds)
+ {
+ if (id == seedCellId || seedCell.VisibleCellIds.Contains(id))
+ keep.Add(id);
+ }
+ if (keep.Count != candidates.Count)
+ {
+ candidates.Clear();
+ foreach (uint id in keep) candidates.Add(id);
+ }
+ }
+ }
+ }
+
+ return candidates.OrderedIds;
+ }
+
///
/// Verbatim port of CEnvCell::find_visible_child_cell
/// (acclient_2013_pseudo_c.txt:311397). Returns the cell whose cell-BSP
diff --git a/tests/AcDream.Core.Tests/Physics/BuildShadowCellSetTests.cs b/tests/AcDream.Core.Tests/Physics/BuildShadowCellSetTests.cs
new file mode 100644
index 00000000..448f100d
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Physics/BuildShadowCellSetTests.cs
@@ -0,0 +1,299 @@
+using System.Collections.Generic;
+using System.Linq;
+using System.Numerics;
+using DatReaderWriter.Enums;
+using DatReaderWriter.Types;
+using AcDream.Core.Physics;
+using Xunit;
+
+namespace AcDream.Core.Tests.Physics;
+
+///
+/// BR-7 / A6.P4 C2 (2026-06-11):
+/// — the registration-side sphere-overlap portal flood, ported from
+/// CObjCell::find_cell_list (Ghidra 0x0052b4e0) as called by
+/// calc_cross_cells(_static) (0x00515230/0x00515160). Synthetic
+/// fixtures mirror CellTransitFindCellSetTests.
+///
+public class BuildShadowCellSetTests
+{
+ private const uint IndoorSeed = 0xA9B40100u;
+ private const uint NeighborCell = 0xA9B40101u;
+
+ private static Sphere[] One(Vector3 center, float radius) =>
+ new[] { new Sphere { Origin = center, Radius = radius } };
+
+ private static CellPhysics MakeCellWithPortalAtRightWall(
+ Matrix4x4 worldTransform, ushort otherCellId, IReadOnlySet? visible = null)
+ {
+ var portalPoly = new ResolvedPolygon
+ {
+ Vertices = new[]
+ {
+ new Vector3(2.5f, -2.5f, 0f),
+ new Vector3(2.5f, 2.5f, 0f),
+ new Vector3(2.5f, 2.5f, 5f),
+ new Vector3(2.5f, -2.5f, 5f),
+ },
+ Plane = new Plane(new Vector3(1, 0, 0), -2.5f), // x = 2.5
+ NumPoints = 4,
+ SidesType = CullMode.None,
+ };
+
+ Matrix4x4.Invert(worldTransform, out var inv);
+ return new CellPhysics
+ {
+ WorldTransform = worldTransform,
+ InverseWorldTransform = inv,
+ Resolved = new Dictionary(),
+ PortalPolygons = new Dictionary { [10] = portalPoly },
+ Portals = new[]
+ {
+ new PortalInfo(otherCellId: otherCellId, polygonId: 10, flags: 0),
+ },
+ CellBSP = new CellBSPTree
+ {
+ Root = new CellBSPNode { Type = BSPNodeType.Leaf },
+ },
+ VisibleCellIds = visible ?? new HashSet(),
+ };
+ }
+
+ private static CellPhysics MakeLeafCell(Matrix4x4 worldTransform)
+ {
+ Matrix4x4.Invert(worldTransform, out var inv);
+ return new CellPhysics
+ {
+ WorldTransform = worldTransform,
+ InverseWorldTransform = inv,
+ Resolved = new Dictionary(),
+ CellBSP = new CellBSPTree
+ {
+ Root = new CellBSPNode { Type = BSPNodeType.Leaf },
+ },
+ };
+ }
+
+ // ── Seeds ──────────────────────────────────────────────────────────
+
+ [Fact]
+ public void IndoorSeed_SphereAwayFromPortals_FloodsSeedOnly()
+ {
+ // Neighbor NOT cached: a leaf-root CellBSP would admit any sphere
+ // (no geometry), so the seed-only case is pinned with the neighbor
+ // unloaded — the unloaded-neighbor portal-side hint (PortalSide=true
+ // for flags=0 → dist > -rad) does not fire at local x=-1
+ // (dist = -3.5 vs -rad = -0.52).
+ var cache = new PhysicsDataCache();
+ cache.RegisterCellStructForTest(IndoorSeed, MakeCellWithPortalAtRightWall(
+ Matrix4x4.Identity, otherCellId: 0x0101));
+
+ var set = CellTransit.BuildShadowCellSet(
+ cache, IndoorSeed, One(new Vector3(-1f, 0f, 2.5f), 0.5f), 1,
+ isStatic: false);
+
+ Assert.Equal(new[] { IndoorSeed }, set);
+ }
+
+ [Fact]
+ public void IndoorSeed_SphereOverlapsNeighborBsp_FloodsNeighbor()
+ {
+ var cache = new PhysicsDataCache();
+ cache.RegisterCellStructForTest(IndoorSeed, MakeCellWithPortalAtRightWall(
+ Matrix4x4.Identity, otherCellId: 0x0101));
+ cache.RegisterCellStructForTest(NeighborCell, MakeLeafCell(
+ Matrix4x4.CreateTranslation(5f, 0f, 0f)));
+
+ var set = CellTransit.BuildShadowCellSet(
+ cache, IndoorSeed, One(new Vector3(2.0f, 0f, 2.5f), 0.5f), 1,
+ isStatic: false);
+
+ Assert.Contains(IndoorSeed, set);
+ Assert.Contains(NeighborCell, set);
+ // Seed first — retail CELLARRAY order (seed added at index 0).
+ Assert.Equal(IndoorSeed, set[0]);
+ }
+
+ [Fact]
+ public void UnloadedIndoorSeed_ReturnsSeedByIdOnly_NoWalk()
+ {
+ // Retail: the unloaded seed is add_cell()'d by id with a null cell
+ // pointer and the walk is skipped (gate on the seed pointer,
+ // 0052b576). The object stays registered under its claimed cell
+ // until re-flood on hydration.
+ var cache = new PhysicsDataCache();
+
+ var set = CellTransit.BuildShadowCellSet(
+ cache, IndoorSeed, One(Vector3.Zero, 0.5f), 1, isStatic: false);
+
+ Assert.Equal(new[] { IndoorSeed }, set);
+ }
+
+ [Fact]
+ public void OutdoorSeed_FloodsOverlappedLandcells_BlockCrossingMath()
+ {
+ var cache = new PhysicsDataCache();
+ // Near the east boundary of landcell grid(0,0): AddAllOutsideCells
+ // adds the east neighbor grid(1,0) (block-local frame, anchor block).
+ var set = CellTransit.BuildShadowCellSet(
+ cache, 0xA9B40001u, One(new Vector3(23.8f, 12f, 0f), 0.5f), 1,
+ isStatic: false);
+
+ Assert.Contains(0xA9B40001u, set);
+ Assert.True(set.Count >= 2,
+ $"Expected >= 2 outdoor cells (seed + east neighbor), got {set.Count}");
+ Assert.All(set, id => Assert.True((id & 0xFFFFu) < 0x0100u));
+ }
+
+ // ── The building bridge (outdoor → indoor at registration) ────────
+
+ [Fact]
+ public void OutdoorSeed_BuildingOnLandcell_SphereInsideInterior_AddsInteriorCell()
+ {
+ // The door shape: an outdoor-positioned object at a doorway whose
+ // flood sphere pokes into the vestibule. Retail writes it into the
+ // vestibule's shadow_object_list at registration via
+ // CLandCell::find_transit_cells → ... → check_building_transit.
+ var cache = new PhysicsDataCache();
+ cache.RegisterCellStructForTest(NeighborCell, MakeLeafCell(Matrix4x4.Identity));
+
+ var sphere = One(new Vector3(12f, 12f, 0f), 0.5f);
+ // Compute the landcell the sphere floods (seed cell of the walk) and
+ // hang the building off it.
+ var probe = CellTransit.BuildShadowCellSet(
+ cache, 0xA9B40001u, sphere, 1, isStatic: false);
+ uint floodedLandcell = probe[0];
+
+ cache.RegisterBuildingForTest(floodedLandcell, new BuildingPhysics
+ {
+ WorldTransform = Matrix4x4.Identity,
+ InverseWorldTransform = Matrix4x4.Identity,
+ Portals = new[]
+ {
+ new BldPortalInfo(NeighborCell, otherPortalId: 0, flags: 0),
+ },
+ });
+
+ var set = CellTransit.BuildShadowCellSet(
+ cache, 0xA9B40001u, sphere, 1, isStatic: false);
+
+ Assert.Contains(floodedLandcell, set);
+ Assert.Contains(NeighborCell, set);
+ }
+
+ [Fact]
+ public void OutdoorSeed_BuildingPortalNegativeId_InteriorNotAdded()
+ {
+ // C1's gate observed through the flood: a portal with
+ // other_portal_id = -1 (wire 0xFFFF) never admits its interior cell
+ // (CEnvCell::check_building_transit, 0x0052c5dc).
+ var cache = new PhysicsDataCache();
+ cache.RegisterCellStructForTest(NeighborCell, MakeLeafCell(Matrix4x4.Identity));
+
+ var sphere = One(new Vector3(12f, 12f, 0f), 0.5f);
+ var probe = CellTransit.BuildShadowCellSet(
+ cache, 0xA9B40001u, sphere, 1, isStatic: false);
+ uint floodedLandcell = probe[0];
+
+ cache.RegisterBuildingForTest(floodedLandcell, new BuildingPhysics
+ {
+ WorldTransform = Matrix4x4.Identity,
+ InverseWorldTransform = Matrix4x4.Identity,
+ Portals = new[]
+ {
+ new BldPortalInfo(NeighborCell, otherPortalId: unchecked((short)0xFFFF), flags: 0),
+ },
+ });
+
+ var set = CellTransit.BuildShadowCellSet(
+ cache, 0xA9B40001u, sphere, 1, isStatic: false);
+
+ Assert.DoesNotContain(NeighborCell, set);
+ }
+
+ // ── Exterior straddle from an indoor seed ──────────────────────────
+
+ [Fact]
+ public void IndoorSeed_ExteriorPortalStraddle_AddsOutsideCells()
+ {
+ // Cell with an EXTERIOR portal (0xFFFF) at x=2.5; sphere straddling
+ // the plane → AddAllOutsideCells fires (retail straddle gate,
+ // 0052c8e5-0052c92d + gate 0052c9d6).
+ var cache = new PhysicsDataCache();
+ cache.RegisterCellStructForTest(IndoorSeed, MakeCellWithPortalAtRightWall(
+ Matrix4x4.Identity, otherCellId: 0xFFFF));
+
+ var set = CellTransit.BuildShadowCellSet(
+ cache, IndoorSeed, One(new Vector3(2.4f, 0f, 2.5f), 0.5f), 1,
+ isStatic: false);
+
+ Assert.Contains(IndoorSeed, set);
+ Assert.Contains(set, id => (id & 0xFFFFu) < 0x0100u);
+ }
+
+ [Fact]
+ public void IndoorSeed_SphereAwayFromExteriorPortal_NoOutsideCells()
+ {
+ var cache = new PhysicsDataCache();
+ cache.RegisterCellStructForTest(IndoorSeed, MakeCellWithPortalAtRightWall(
+ Matrix4x4.Identity, otherCellId: 0xFFFF));
+
+ var set = CellTransit.BuildShadowCellSet(
+ cache, IndoorSeed, One(new Vector3(-1f, 0f, 2.5f), 0.5f), 1,
+ isStatic: false);
+
+ Assert.Equal(new[] { IndoorSeed }, set);
+ }
+
+ // ── Static prune (do_not_load_cells) ───────────────────────────────
+
+ [Fact]
+ public void StaticIndoorSeed_PrunesCellsOutsideStabList()
+ {
+ // The neighbor is flooded by sphere overlap but is NOT in the seed's
+ // stab list → calc_cross_cells_static's do_not_load_cells prune
+ // (0052b66e) drops it. {seed} survives unconditionally.
+ var cache = new PhysicsDataCache();
+ cache.RegisterCellStructForTest(IndoorSeed, MakeCellWithPortalAtRightWall(
+ Matrix4x4.Identity, otherCellId: 0x0101, visible: new HashSet()));
+ cache.RegisterCellStructForTest(NeighborCell, MakeLeafCell(
+ Matrix4x4.CreateTranslation(5f, 0f, 0f)));
+
+ var sphere = One(new Vector3(2.0f, 0f, 2.5f), 0.5f);
+
+ var dynamicSet = CellTransit.BuildShadowCellSet(
+ cache, IndoorSeed, sphere, 1, isStatic: false);
+ Assert.Contains(NeighborCell, dynamicSet);
+
+ var staticSet = CellTransit.BuildShadowCellSet(
+ cache, IndoorSeed, sphere, 1, isStatic: true);
+ Assert.Equal(new[] { IndoorSeed }, staticSet);
+ }
+
+ [Fact]
+ public void StaticIndoorSeed_KeepsCellsInStabList()
+ {
+ var cache = new PhysicsDataCache();
+ cache.RegisterCellStructForTest(IndoorSeed, MakeCellWithPortalAtRightWall(
+ Matrix4x4.Identity, otherCellId: 0x0101,
+ visible: new HashSet { NeighborCell }));
+ cache.RegisterCellStructForTest(NeighborCell, MakeLeafCell(
+ Matrix4x4.CreateTranslation(5f, 0f, 0f)));
+
+ var staticSet = CellTransit.BuildShadowCellSet(
+ cache, IndoorSeed, One(new Vector3(2.0f, 0f, 2.5f), 0.5f), 1,
+ isStatic: true);
+
+ Assert.Contains(IndoorSeed, staticSet);
+ Assert.Contains(NeighborCell, staticSet);
+ }
+
+ [Fact]
+ public void ZeroSpheres_ReturnsEmpty()
+ {
+ var cache = new PhysicsDataCache();
+ var set = CellTransit.BuildShadowCellSet(
+ cache, IndoorSeed, System.Array.Empty(), 0, isStatic: false);
+ Assert.Empty(set);
+ }
+}