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() {