diff --git a/docs/ISSUES.md b/docs/ISSUES.md
index 90dbc06..8c97087 100644
--- a/docs/ISSUES.md
+++ b/docs/ISSUES.md
@@ -177,33 +177,6 @@ missing is the plugin-API surface.
---
-## #31 — Low outdoor cell id can go stale after transition movement
-
-**Status:** OPEN
-**Severity:** HIGH
-**Filed:** 2026-04-29
-**Component:** physics / cells / movement
-
-**Description:** Local movement can cross 24m outdoor cell boundaries while
-the low cell id used for outbound full cell id remains stale. This can combine
-correct landblock high bits with the wrong outdoor-cell low byte.
-
-**Root cause / status:** Tracked under Phase L.2e. `CELLARRAY`,
-`CObjCell::find_cell_list`, adjacent-cell checks, and low-cell ownership are
-not fully ported.
-
-**Files:** `src/AcDream.Core/Physics/PhysicsEngine.cs`,
-`src/AcDream.Core/Physics/TransitionTypes.cs`,
-`src/AcDream.App/Input/PlayerMovementController.cs`.
-
-**Research:** `docs/plans/2026-04-29-movement-collision-conformance.md`.
-
-**Acceptance:** Crossing a 24m outdoor-cell seam updates the local resolved
-cell id and the outbound full cell id. Tests cover intra-landblock seams and
-landblock-edge seams.
-
----
-
## #32 — Retail edge-slide / cliff-slide / precipice-slide incomplete
**Status:** OPEN
@@ -440,6 +413,18 @@ If hypothesis (a) is correct, this issue effectively rolls into **#28** — the
# Recently closed
+## #31 — [DONE 2026-04-29] Low outdoor cell id can go stale after transition movement
+
+**Closed:** 2026-04-29
+**Commit:** `(this commit)`
+**Resolution:** `ResolveWithTransition` now refreshes outdoor cell ownership
+from the resolved world position while the sphere sweep runs. Intra-landblock
+24m outdoor seams update the low cell id, and full-cell callers crossing a
+landblock seam get the destination landblock prefix plus the correct outdoor
+low cell.
+
+---
+
## #34 — [DONE 2026-04-29] Missing routine local/server correction diagnostic
**Closed:** 2026-04-29
diff --git a/memory/project_movement_collision_conformance.md b/memory/project_movement_collision_conformance.md
index 8dd54d3..66cbfdb 100644
--- a/memory/project_movement_collision_conformance.md
+++ b/memory/project_movement_collision_conformance.md
@@ -55,3 +55,7 @@ InputDispatcher / PlayerMovementController
`move-truth ECHO` for player `UpdatePosition` echoes, including local/server
delta. `GameWindow` now passes explicit grounded/airborne contact bytes from
`MovementResult.IsOnGround` to both movement packet builders.
+- 2026-04-29: L.2e first cell-ownership fix. `ResolveWithTransition` refreshes
+ outdoor cell ownership from world position during the sphere sweep, so 24m
+ outdoor seams update low cell ids and full-cell callers crossing landblock
+ seams get the destination landblock prefix plus the correct outdoor low cell.
diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs
index 4a93403..be2494b 100644
--- a/src/AcDream.Core/Physics/PhysicsEngine.cs
+++ b/src/AcDream.Core/Physics/PhysicsEngine.cs
@@ -162,6 +162,38 @@ public sealed class PhysicsEngine
return null;
}
+ ///
+ /// Resolve the outdoor cell id that owns a world-space position.
+ /// Indoor ids are preserved because EnvCell ownership still comes from
+ /// portal/cell BSP state; outdoor ids are derived from the registered
+ /// landblock that currently contains the point.
+ ///
+ internal uint ResolveOutdoorCellId(Vector3 worldPos, uint fallbackCellId)
+ {
+ if (fallbackCellId == 0)
+ return 0;
+
+ uint fallbackLow = fallbackCellId & 0xFFFFu;
+ if (fallbackLow >= 0x0100u)
+ return fallbackCellId;
+
+ foreach (var kvp in _landblocks)
+ {
+ var lb = kvp.Value;
+ float localX = worldPos.X - lb.WorldOffsetX;
+ float localY = worldPos.Y - lb.WorldOffsetY;
+ if (localX >= 0f && localX < 192f && localY >= 0f && localY < 192f)
+ {
+ uint lowCellId = lb.Terrain.ComputeOutdoorCellId(localX, localY);
+ return (fallbackCellId & 0xFFFF0000u) == 0
+ ? lowCellId
+ : (kvp.Key & 0xFFFF0000u) | lowCellId;
+ }
+ }
+
+ return fallbackCellId;
+ }
+
///
/// Resolve an entity's movement from by
/// applying (XY only) and computing the correct Z
@@ -471,7 +503,10 @@ public sealed class PhysicsEngine
bool onGround = ci.ContactPlaneValid
|| transition.ObjectInfo.State.HasFlag(ObjectInfoState.OnWalkable);
- return new ResolveResult(sp.CheckPos, sp.CheckCellId, onGround);
+ return new ResolveResult(
+ sp.CheckPos,
+ ResolveOutdoorCellId(sp.CheckPos, sp.CheckCellId),
+ onGround);
}
// Transition failed (e.g., stuck in corner, too many steps).
@@ -483,6 +518,10 @@ public sealed class PhysicsEngine
|| transition.ObjectInfo.State.HasFlag(ObjectInfoState.OnWalkable)
|| isOnGround;
- return new ResolveResult(sp.CheckPos, sp.CheckCellId != 0 ? sp.CheckCellId : cellId, partialOnGround);
+ uint partialCellId = sp.CheckCellId != 0 ? sp.CheckCellId : cellId;
+ return new ResolveResult(
+ sp.CheckPos,
+ ResolveOutdoorCellId(sp.CheckPos, partialCellId),
+ partialOnGround);
}
}
diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs
index 6494789..5b11b10 100644
--- a/src/AcDream.Core/Physics/TransitionTypes.cs
+++ b/src/AcDream.Core/Physics/TransitionTypes.cs
@@ -705,6 +705,10 @@ public sealed class Transition
var sp = SpherePath;
var ci = CollisionInfo;
+ uint resolvedOutdoorCellId = engine.ResolveOutdoorCellId(sp.CheckPos, sp.CheckCellId);
+ if (resolvedOutdoorCellId != sp.CheckCellId)
+ sp.SetCheckPos(sp.CheckPos, resolvedOutdoorCellId);
+
Vector3 footCenter = sp.GlobalSphere[0].Origin;
float sphereRadius = sp.GlobalSphere[0].Radius;
diff --git a/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs b/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs
index d6e6e55..0f212fa 100644
--- a/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs
@@ -190,6 +190,54 @@ public class PhysicsEngineTests
Assert.True(result.Position.X > 192f);
}
+ [Fact]
+ public void ResolveWithTransition_OutdoorCellBoundary_UpdatesLowCellId()
+ {
+ var engine = MakeFlatEngine(terrainZ: 50f);
+
+ var result = engine.ResolveWithTransition(
+ currentPos: new Vector3(23f, 10f, 50f),
+ targetPos: new Vector3(25f, 10f, 50f),
+ cellId: 0x0001u,
+ sphereRadius: 0.5f,
+ sphereHeight: 1.2f,
+ stepUpHeight: 0.4f,
+ stepDownHeight: 0.4f,
+ isOnGround: true);
+
+ Assert.True(result.IsOnGround);
+ Assert.InRange(result.Position.X, 24.9f, 25.1f);
+ Assert.Equal(0x0009u, result.CellId);
+ }
+
+ [Fact]
+ public void ResolveWithTransition_LandblockBoundary_UpdatesFullOutdoorCellId()
+ {
+ var engine = new PhysicsEngine();
+
+ var terrainA = new TerrainSurface(FlatHeightmap(50), LinearHeightTable());
+ engine.AddLandblock(0xA9B4FFFFu, terrainA, Array.Empty(),
+ Array.Empty(), worldOffsetX: 0f, worldOffsetY: 0f);
+
+ var terrainB = new TerrainSurface(FlatHeightmap(50), LinearHeightTable());
+ engine.AddLandblock(0xAAB4FFFFu, terrainB, Array.Empty(),
+ Array.Empty(), worldOffsetX: 192f, worldOffsetY: 0f);
+
+ var result = engine.ResolveWithTransition(
+ currentPos: new Vector3(191f, 10f, 50f),
+ targetPos: new Vector3(193f, 10f, 50f),
+ cellId: 0xA9B40039u,
+ sphereRadius: 0.5f,
+ sphereHeight: 1.2f,
+ stepUpHeight: 0.4f,
+ stepDownHeight: 0.4f,
+ isOnGround: true);
+
+ Assert.True(result.IsOnGround);
+ Assert.InRange(result.Position.X, 192.9f, 193.1f);
+ Assert.Equal(0xAAB40001u, result.CellId);
+ }
+
[Fact]
public void Resolve_LeaveIndoorCell_TransitionsToOutdoor()
{