fix(physics): #31 update outdoor cell id during transition movement

This commit is contained in:
Erik 2026-04-29 22:00:30 +02:00
parent 3be0c8b7c7
commit 9fea9b13ad
5 changed files with 109 additions and 29 deletions

View file

@ -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

View file

@ -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.

View file

@ -162,6 +162,38 @@ public sealed class PhysicsEngine
return null;
}
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// Resolve an entity's movement from <paramref name="currentPos"/> by
/// applying <paramref name="delta"/> (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);
}
}

View file

@ -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;

View file

@ -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<CellSurface>(),
Array.Empty<PortalPlane>(), worldOffsetX: 0f, worldOffsetY: 0f);
var terrainB = new TerrainSurface(FlatHeightmap(50), LinearHeightTable());
engine.AddLandblock(0xAAB4FFFFu, terrainB, Array.Empty<CellSurface>(),
Array.Empty<PortalPlane>(), 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()
{