diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs
index 977cd8a..11dbd7c 100644
--- a/src/AcDream.Core/Physics/TransitionTypes.cs
+++ b/src/AcDream.Core/Physics/TransitionTypes.cs
@@ -1387,6 +1387,121 @@ public sealed class Transition
/// Ported from pseudocode section 4 (LandCell.FindEnvCollisions + ValidateWalkable).
/// ACE: LandCell.FindEnvCollisions / ObjectInfo.ValidateWalkable.
///
+
+ ///
+ /// Phase A4 (2026-05-20). Port of retail's
+ /// CTransition::check_other_cells at
+ /// acclient_2013_pseudo_c.txt:272717-272798.
+ ///
+ ///
+ /// After the primary cell's BSP collision returns OK, iterate every
+ /// other cell in the sphere's overlap set and run BSP collision
+ /// against each. Halt on the first Collided/Adjusted/Slid; OK
+ /// continues. Mirrors retail's behaviour exactly — no save/restore
+ /// of state between cells.
+ ///
+ ///
+ internal TransitionState CheckOtherCells(
+ PhysicsEngine engine,
+ Vector3 footCenter,
+ float sphereRadius,
+ System.Collections.Generic.IReadOnlyCollection cellSet)
+ {
+ if (engine.DataCache is null) return TransitionState.OK;
+ var sp = SpherePath;
+
+ // Deterministic order for greppable probe logs. Skip the primary
+ // cell — caller has already run its BSP.
+ var ordered = new System.Collections.Generic.List(cellSet);
+ ordered.Sort();
+
+ foreach (uint cellId in ordered)
+ {
+ if (cellId == sp.CheckCellId) continue;
+
+ var cell = engine.DataCache.GetCellStruct(cellId);
+ // R2 guard: stale CellPhysics loaded for render but not physics.
+ if (cell?.BSP?.Root is null) continue;
+
+ // Transform sphere into THIS cell's local space. Mirrors the
+ // primary-cell pattern at TransitionTypes.cs (FindEnvCollisions,
+ // ~line 1413) AND the Bug B world-origin fix that decomposes
+ // WorldTransform per cell so BSP Path-3 + Path-4 land write
+ // world-space ContactPlanes.
+ var localCenter = Vector3.Transform(footCenter, cell.InverseWorldTransform);
+ var localCurrCenter = Vector3.Transform(sp.GlobalCurrCenter[0].Origin, cell.InverseWorldTransform);
+
+ var localSphere = new DatReaderWriter.Types.Sphere
+ {
+ Origin = localCenter,
+ Radius = sphereRadius,
+ };
+ DatReaderWriter.Types.Sphere? localSphere1 = null;
+ if (sp.NumSphere > 1)
+ {
+ localSphere1 = new DatReaderWriter.Types.Sphere
+ {
+ Origin = Vector3.Transform(sp.GlobalSphere[1].Origin, cell.InverseWorldTransform),
+ Radius = sp.GlobalSphere[1].Radius,
+ };
+ }
+
+ System.Numerics.Quaternion cellRotation;
+ Vector3 cellOrigin;
+ if (!System.Numerics.Matrix4x4.Decompose(cell.WorldTransform, out _,
+ out cellRotation, out cellOrigin))
+ {
+ Console.WriteLine(System.FormattableString.Invariant(
+ $"[other-cells] WARN cell 0x{cellId:X8} WorldTransform did not decompose — falling back to identity rotation"));
+ cellRotation = System.Numerics.Quaternion.Identity;
+ cellOrigin = cell.WorldTransform.Translation;
+ }
+
+ var result = BSPQuery.FindCollisions(
+ cell.BSP.Root, cell.Resolved, this,
+ localSphere, localSphere1, localCurrCenter,
+ Vector3.UnitZ, 1.0f, cellRotation, engine,
+ worldOrigin: cellOrigin);
+
+ if (PhysicsDiagnostics.ProbeIndoorBspEnabled)
+ {
+ Console.WriteLine(System.FormattableString.Invariant(
+ $"[other-cells] primary=0x{sp.CheckCellId:X8} iter=0x{cellId:X8} result={result}"));
+ }
+
+ if (ApplyOtherCellResult(result, out var halted))
+ return halted;
+ }
+
+ return TransitionState.OK;
+ }
+
+ ///
+ /// Phase A4 (2026-05-20). Combine helper for
+ /// . Mirrors retail's switch at
+ /// acclient_2013_pseudo_c.txt:272739-272752:
+ /// Collided/Adjusted halt with CollidedWithEnvironment; Slid
+ /// halts AND clears the contact-plane fields; OK continues.
+ ///
+ internal bool ApplyOtherCellResult(TransitionState result, out TransitionState finalState)
+ {
+ finalState = result;
+ switch (result)
+ {
+ case TransitionState.Collided:
+ case TransitionState.Adjusted:
+ if (!ObjectInfo.State.HasFlag(ObjectInfoState.Contact))
+ CollisionInfo.CollidedWithEnvironment = true;
+ return true;
+ case TransitionState.Slid:
+ CollisionInfo.ContactPlaneValid = false;
+ CollisionInfo.ContactPlaneIsWater = false;
+ return true;
+ default:
+ return false;
+ }
+ }
+
private TransitionState FindEnvCollisions(PhysicsEngine engine)
{
var sp = SpherePath;
diff --git a/tests/AcDream.Core.Tests/Physics/TransitionCheckOtherCellsTests.cs b/tests/AcDream.Core.Tests/Physics/TransitionCheckOtherCellsTests.cs
new file mode 100644
index 0000000..fe69e15
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Physics/TransitionCheckOtherCellsTests.cs
@@ -0,0 +1,142 @@
+using System;
+using System.Collections.Generic;
+using System.Numerics;
+using DatReaderWriter.Enums;
+using DatReaderWriter.Types;
+using AcDream.Core.Physics;
+using Xunit;
+
+namespace AcDream.Core.Tests.Physics;
+
+///
+/// Unit tests for the result-combine helper used by
+/// . The iteration / per-cell
+/// BSP-query parts are covered end-to-end by
+/// ; this file pins the
+/// retail-faithful halt semantics that
+/// acclient_2013_pseudo_c.txt:272739-272752 spells out.
+///
+public class TransitionCheckOtherCellsTests
+{
+ private static Transition MakeTransition(bool contactFlag = false)
+ {
+ var t = new Transition();
+ t.SpherePath.InitPath(Vector3.Zero, Vector3.Zero, cellId: 0xA9B40100u, sphereRadius: 0.48f);
+ t.ObjectInfo.State = contactFlag ? ObjectInfoState.Contact : ObjectInfoState.None;
+ // Pre-set CP fields to non-default so the Slid-clears-CP assertion
+ // can detect the clear.
+ t.CollisionInfo.ContactPlaneValid = true;
+ t.CollisionInfo.ContactPlaneIsWater = true;
+ return t;
+ }
+
+ [Fact]
+ public void OK_ContinuesIteration_DoesNotMutate()
+ {
+ var t = MakeTransition();
+
+ bool halt = t.ApplyOtherCellResult(TransitionState.OK, out var finalState);
+
+ Assert.False(halt);
+ Assert.Equal(TransitionState.OK, finalState);
+ Assert.True(t.CollisionInfo.ContactPlaneValid);
+ Assert.True(t.CollisionInfo.ContactPlaneIsWater);
+ Assert.False(t.CollisionInfo.CollidedWithEnvironment);
+ }
+
+ [Fact]
+ public void Collided_HaltsAndSetsCollidedWithEnvironment_WhenNotInContact()
+ {
+ var t = MakeTransition(contactFlag: false);
+
+ bool halt = t.ApplyOtherCellResult(TransitionState.Collided, out var finalState);
+
+ Assert.True(halt);
+ Assert.Equal(TransitionState.Collided, finalState);
+ Assert.True(t.CollisionInfo.CollidedWithEnvironment);
+ }
+
+ [Fact]
+ public void Collided_DoesNotSetCollidedWithEnvironment_WhenInContact()
+ {
+ // Retail oracle gating: the CollidedWithEnvironment flip mirrors
+ // the existing primary-cell behavior in FindEnvCollisions —
+ // skipped when ObjectInfo.State has Contact bit set.
+ var t = MakeTransition(contactFlag: true);
+
+ bool halt = t.ApplyOtherCellResult(TransitionState.Collided, out var finalState);
+
+ Assert.True(halt);
+ Assert.Equal(TransitionState.Collided, finalState);
+ Assert.False(t.CollisionInfo.CollidedWithEnvironment);
+ }
+
+ [Fact]
+ public void Adjusted_HaltsAndSetsCollidedWithEnvironment_WhenNotInContact()
+ {
+ var t = MakeTransition(contactFlag: false);
+
+ bool halt = t.ApplyOtherCellResult(TransitionState.Adjusted, out var finalState);
+
+ Assert.True(halt);
+ Assert.Equal(TransitionState.Adjusted, finalState);
+ Assert.True(t.CollisionInfo.CollidedWithEnvironment);
+ }
+
+ [Fact]
+ public void Slid_HaltsAndClearsContactPlaneFields()
+ {
+ // Retail oracle: acclient_2013_pseudo_c.txt:272746-272750
+ // case 4:
+ // this->collision_info.contact_plane_valid = 0;
+ // this->collision_info.contact_plane_is_water = 0;
+ // return result;
+ var t = MakeTransition();
+ Assert.True(t.CollisionInfo.ContactPlaneValid); // pre-condition
+ Assert.True(t.CollisionInfo.ContactPlaneIsWater); // pre-condition
+
+ bool halt = t.ApplyOtherCellResult(TransitionState.Slid, out var finalState);
+
+ Assert.True(halt);
+ Assert.Equal(TransitionState.Slid, finalState);
+ Assert.False(t.CollisionInfo.ContactPlaneValid);
+ Assert.False(t.CollisionInfo.ContactPlaneIsWater);
+ }
+
+ [Fact]
+ public void CheckOtherCells_CellWithNullBspRoot_IsSkippedNoCrash()
+ {
+ // Iteration safety: a CellPhysics in the candidate set with
+ // `BSP = null` (loaded for render but not physics) must be skipped,
+ // not crash. Matches the spec's R2 guard at design §Edge cases E2.
+ var cell = new CellPhysics
+ {
+ BSP = null, // <-- the guard target
+ WorldTransform = Matrix4x4.Identity,
+ InverseWorldTransform = Matrix4x4.Identity,
+ Resolved = new Dictionary(),
+ };
+
+ var engine = new PhysicsEngine();
+ engine.DataCache = new PhysicsDataCache(); // PhysicsEngine has nullable DataCache
+ // FindEnvCollisions has terrain probes downstream; populate a
+ // minimal landblock so the cache + engine are coherent. The cell
+ // we test against doesn't need a real landblock entry.
+ var heights = new byte[81];
+ Array.Fill(heights, (byte)0);
+ var ht = new float[256];
+ for (int i = 0; i < 256; i++) ht[i] = i * 1.0f;
+ engine.AddLandblock(0xA9B4FFFFu, new TerrainSurface(heights, ht),
+ Array.Empty(), Array.Empty(),
+ worldOffsetX: 0f, worldOffsetY: 0f);
+ engine.DataCache.RegisterCellStructForTest(0xA9B40157u, cell);
+
+ var t = MakeTransition();
+ var cellSet = new HashSet { 0xA9B40157u };
+
+ // Call CheckOtherCells directly via the internal seam.
+ var result = t.CheckOtherCells(engine, Vector3.Zero, 0.48f, cellSet);
+
+ Assert.Equal(TransitionState.OK, result);
+ }
+}