diff --git a/src/AcDream.Core/Physics/CellTransit.cs b/src/AcDream.Core/Physics/CellTransit.cs
index 564a46f..bd62333 100644
--- a/src/AcDream.Core/Physics/CellTransit.cs
+++ b/src/AcDream.Core/Physics/CellTransit.cs
@@ -223,7 +223,8 @@ public static class CellTransit
/// finds the cell whose contains
/// the sphere center, and returns its full id (landblock-prefixed).
/// Falls back to when no candidate
- /// matches.
+ /// matches. The candidate set built internally is discarded; use
+ /// to recover it.
///
///
///
@@ -238,7 +239,47 @@ public static class CellTransit
float sphereRadius,
uint currentCellId)
{
- var candidates = new HashSet();
+ return BuildCellSetAndPickContaining(
+ cache, worldSphereCenter, sphereRadius, currentCellId,
+ out _);
+ }
+
+ ///
+ /// Phase A4 (2026-05-20). Same portal-graph traversal as
+ /// but additionally returns the full
+ /// candidate set built during traversal. Used by
+ /// to iterate every cell
+ /// the sphere overlaps for per-cell BSP collision.
+ ///
+ ///
+ /// Retail oracle: CTransition::check_other_cells at
+ /// acclient_2013_pseudo_c.txt:272717-272798 calls
+ /// CObjCell::find_cell_list(&this->cell_array, &var_4c, ...)
+ /// which fills both the cell_array (set) and var_4c (containing cell).
+ ///
+ ///
+ public static uint FindCellSet(
+ PhysicsDataCache cache,
+ Vector3 worldSphereCenter,
+ float sphereRadius,
+ uint currentCellId,
+ out IReadOnlyCollection cellSet)
+ {
+ var containing = BuildCellSetAndPickContaining(
+ cache, worldSphereCenter, sphereRadius, currentCellId,
+ out var candidates);
+ cellSet = candidates;
+ return containing;
+ }
+
+ private static uint BuildCellSetAndPickContaining(
+ PhysicsDataCache cache,
+ Vector3 worldSphereCenter,
+ float sphereRadius,
+ uint currentCellId,
+ out HashSet candidates)
+ {
+ candidates = new HashSet();
uint currentLow = currentCellId & 0xFFFFu;
if (currentLow >= 0x0100u)
diff --git a/tests/AcDream.Core.Tests/Physics/CellTransitFindCellSetTests.cs b/tests/AcDream.Core.Tests/Physics/CellTransitFindCellSetTests.cs
new file mode 100644
index 0000000..76917e9
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Physics/CellTransitFindCellSetTests.cs
@@ -0,0 +1,137 @@
+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;
+
+public class CellTransitFindCellSetTests
+{
+ // ──────────────────────────────────────────────────────────────────
+ // Helpers — mirror CellTransitFindTransitCellsSphereTests.cs pattern
+ // ──────────────────────────────────────────────────────────────────
+
+ private static CellPhysics MakeCellWithPortalAtRightWall(
+ Matrix4x4 worldTransform, uint otherCellId, ushort flags)
+ {
+ 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: (ushort)otherCellId, polygonId: 10, flags: flags),
+ },
+ CellBSP = new CellBSPTree
+ {
+ Root = new CellBSPNode { Type = BSPNodeType.Leaf },
+ }
+ };
+ }
+
+ // ──────────────────────────────────────────────────────────────────
+ // Tests
+ // ──────────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void Sphere_FullyInsidePrimaryCell_ReturnsOnlyPrimary()
+ {
+ var cellA = MakeCellWithPortalAtRightWall(Matrix4x4.Identity, otherCellId: 0x0101, flags: 0);
+ var cache = new PhysicsDataCache();
+ cache.RegisterCellStructForTest(0xA9B40100u, cellA);
+
+ // Sphere far from any portal — local x=-1, reach to x=-0.5; portal at x=2.5.
+ var sphereCenter = new Vector3(-1.0f, 0f, 2.5f);
+
+ uint containing = CellTransit.FindCellSet(
+ cache, sphereCenter, sphereRadius: 0.5f,
+ currentCellId: 0xA9B40100u,
+ out var cellSet);
+
+ Assert.Equal(0xA9B40100u, containing);
+ Assert.Single(cellSet);
+ Assert.Contains(0xA9B40100u, cellSet);
+ }
+
+ [Fact]
+ public void Sphere_StraddlingPortal_ReturnsBothCells()
+ {
+ var cellA = MakeCellWithPortalAtRightWall(Matrix4x4.Identity, otherCellId: 0x0101, flags: 0);
+ var cellBT = Matrix4x4.CreateTranslation(new Vector3(5f, 0f, 0f));
+ Matrix4x4.Invert(cellBT, out var cellBInv);
+ var cellB = new CellPhysics
+ {
+ WorldTransform = cellBT,
+ InverseWorldTransform = cellBInv,
+ Resolved = new Dictionary(),
+ CellBSP = new CellBSPTree
+ {
+ Root = new CellBSPNode { Type = BSPNodeType.Leaf },
+ }
+ };
+
+ var cache = new PhysicsDataCache();
+ cache.RegisterCellStructForTest(0xA9B40100u, cellA);
+ cache.RegisterCellStructForTest(0xA9B40101u, cellB);
+
+ // Sphere center at local x=2.0, radius=0.5 → reaches x=2.5 = portal plane.
+ var sphereCenter = new Vector3(2.0f, 0f, 2.5f);
+
+ uint containing = CellTransit.FindCellSet(
+ cache, sphereCenter, sphereRadius: 0.5f,
+ currentCellId: 0xA9B40100u,
+ out var cellSet);
+
+ Assert.Contains(0xA9B40100u, cellSet);
+ Assert.Contains(0xA9B40101u, cellSet);
+ }
+
+ [Fact]
+ public void FindCellSet_OutdoorSeed_IncludesNeighbourLandcells()
+ {
+ var cache = new PhysicsDataCache();
+ // Outdoor seed near a cell boundary — expand to neighbours via
+ // AddAllOutsideCells. Landcells have no CellPhysics in cache, so
+ // they appear in the set but the containing-cell loop falls back
+ // to currentCellId. The point of this test: the SET captures
+ // them even though FindCellList's single-uint return cannot.
+ //
+ // World coords for landblock 0xA9B4FFFF: origin at
+ // (0xA9*192, 0xB4*192) = (32448, 34560). Cell grid(0,0) covers
+ // world XY in [(32448,34560), (32472,34584)). Place the sphere
+ // center near the east boundary of grid(0,0) so AddAllOutsideCells
+ // adds the east neighbour grid(1,0).
+ uint lbPrefix = 0xA9B40000u;
+ float lbX = ((lbPrefix >> 24) & 0xFFu) * 192f;
+ float lbY = ((lbPrefix >> 16) & 0xFFu) * 192f;
+ var sphereCenter = new Vector3(lbX + 23.8f, lbY + 12f, 0f);
+
+ uint containing = CellTransit.FindCellSet(
+ cache, sphereCenter, sphereRadius: 0.5f,
+ currentCellId: 0xA9B40001u, // outdoor cell, low byte < 0x100
+ out var cellSet);
+
+ Assert.Equal(0xA9B40001u, containing);
+ Assert.True(cellSet.Count >= 2, $"Expected ≥2 cells in set (primary + east neighbour), got {cellSet.Count}");
+ }
+}