diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs
index e78d74df..72347120 100644
--- a/src/AcDream.Core/Physics/PhysicsEngine.cs
+++ b/src/AcDream.Core/Physics/PhysicsEngine.cs
@@ -385,33 +385,97 @@ public sealed class PhysicsEngine
float localX = worldX - lb.WorldOffsetX;
float localY = worldY - lb.WorldOffsetY;
if (localX >= 0f && localX < 192f && localY >= 0f && localY < 192f)
- {
- var sample = lb.Terrain.SampleSurfacePolygon(localX, localY);
- var vertices = new TerrainTriangleVertices(
- OffsetTerrainVertex(sample.Vertices.V0, lb),
- OffsetTerrainVertex(sample.Vertices.V1, lb),
- OffsetTerrainVertex(sample.Vertices.V2, lb));
-
- var normal = sample.Normal;
- float d = -Vector3.Dot(normal, vertices[0]);
- var plane = new System.Numerics.Plane(normal, d);
-
- float waterDepth = lb.Terrain.SampleWaterDepth(localX, localY);
- bool isWater = waterDepth >= 0.45f;
- uint lowCellId = lb.Terrain.ComputeOutdoorCellId(localX, localY);
- uint fullCellId = (kvp.Key & 0xFFFF0000u) | lowCellId;
-
- return new TerrainWalkableSample(
- plane,
- vertices,
- waterDepth,
- isWater,
- fullCellId);
- }
+ return BuildTerrainWalkableSample(kvp.Key, lb, localX, localY);
}
return null;
}
+ ///
+ /// Samples only the fixed outdoor cell supplied to retail
+ /// CTransition::insert_into_cell. The target point may move into a
+ /// neighboring cell during a retry, but retail continues dispatching the
+ /// captured CObjCell* until that inner call returns.
+ ///
+ internal TerrainWalkableSample? SampleTerrainWalkableInCell(
+ uint cellId,
+ float worldX,
+ float worldY)
+ {
+ uint lowCellId = cellId & 0xFFFFu;
+ if (lowCellId is < 1u or > 0x40u)
+ return null;
+
+ foreach (var kvp in _landblocks)
+ {
+ uint requestedPrefix = cellId & 0xFFFF0000u;
+ if (requestedPrefix != 0u &&
+ (kvp.Key & 0xFFFF0000u) != requestedPrefix)
+ continue;
+
+ LandblockPhysics lb = kvp.Value;
+ float localX = worldX - lb.WorldOffsetX;
+ float localY = worldY - lb.WorldOffsetY;
+ if (requestedPrefix == 0u &&
+ (localX < 0f || localX >= 192f ||
+ localY < 0f || localY >= 192f))
+ {
+ continue;
+ }
+ int cellIndex = (int)lowCellId - 1;
+ int cellX = cellIndex / TerrainSurface.CellsPerSide;
+ int cellY = cellIndex % TerrainSurface.CellsPerSide;
+ float minX = cellX * TerrainSurface.CellSize;
+ float minY = cellY * TerrainSurface.CellSize;
+ float maxX = minX + TerrainSurface.CellSize;
+ float maxY = minY + TerrainSurface.CellSize;
+
+ if (localX < minX || localX >= maxX ||
+ localY < minY || localY >= maxY)
+ {
+ return null;
+ }
+
+ return BuildTerrainWalkableSample(
+ kvp.Key,
+ lb,
+ localX,
+ localY);
+ }
+
+ return null;
+ }
+
+ private static TerrainWalkableSample BuildTerrainWalkableSample(
+ uint landblockId,
+ LandblockPhysics landblock,
+ float localX,
+ float localY)
+ {
+ TerrainSurfacePolygon sample = landblock.Terrain.SampleSurfacePolygon(
+ localX,
+ localY);
+ var vertices = new TerrainTriangleVertices(
+ OffsetTerrainVertex(sample.Vertices.V0, landblock),
+ OffsetTerrainVertex(sample.Vertices.V1, landblock),
+ OffsetTerrainVertex(sample.Vertices.V2, landblock));
+
+ Vector3 normal = sample.Normal;
+ float d = -Vector3.Dot(normal, vertices[0]);
+ var plane = new System.Numerics.Plane(normal, d);
+
+ float waterDepth = landblock.Terrain.SampleWaterDepth(localX, localY);
+ bool isWater = waterDepth >= 0.45f;
+ uint lowCellId = landblock.Terrain.ComputeOutdoorCellId(localX, localY);
+ uint fullCellId = (landblockId & 0xFFFF0000u) | lowCellId;
+
+ return new TerrainWalkableSample(
+ plane,
+ vertices,
+ waterDepth,
+ isWater,
+ fullCellId);
+ }
+
private static Vector3 OffsetTerrainVertex(Vector3 vertex, LandblockPhysics landblock)
=> new(
vertex.X + landblock.WorldOffsetX,
@@ -466,11 +530,11 @@ public sealed class PhysicsEngine
}
///
- /// TEST-ONLY outdoor cell re-derive. The single caller is
- /// Transition.FindEnvCollisions's cache-null fallback
+ /// TEST-ONLY outdoor cell re-derive. The sole caller is
+ /// Transition.RunCheckOtherCellsAndAdvance's cache-null fallback
/// (PhysicsEngineTests run engines without a ,
- /// so is unavailable). Production
- /// membership flows exclusively through the collide-then-pick advance
+ /// so is unavailable). Normal
+ /// production membership flows exclusively through the collide-then-pick advance
/// (RunCheckOtherCellsAndAdvance → FindCellSet).
///
///
diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs
index b8aa4093..f98776a0 100644
--- a/src/AcDream.Core/Physics/TransitionTypes.cs
+++ b/src/AcDream.Core/Physics/TransitionTypes.cs
@@ -2138,7 +2138,7 @@ public sealed class Transition
TransitionState state = TransitionState.OK;
for (int attempt = 0; attempt < numAttempts; attempt++)
{
- state = FindPrimaryCellCollisions(engine, attempt);
+ state = FindPrimaryCellCollisions(engine, cellId, attempt);
if (state is TransitionState.OK or TransitionState.Collided)
return state;
@@ -2159,31 +2159,30 @@ public sealed class Transition
///
private TransitionState FindPrimaryCellCollisions(
PhysicsEngine engine,
+ uint cellId,
int innerAttempt)
{
- TransitionState actualEnvironment = FindEnvCollisions(engine);
- uint currentCellId = SpherePath.CheckCellId;
TransitionState environment = ObservePrimaryCellPhase(
engine,
TransitionCellCollisionPhase.Environment,
- currentCellId,
- actualEnvironment);
+ cellId,
+ FindEnvCollisions(engine, cellId));
if (environment != TransitionState.OK)
return environment;
TransitionState building = ObservePrimaryCellPhase(
engine,
TransitionCellCollisionPhase.Building,
- currentCellId,
- FindBuildingCollisions(engine, currentCellId));
+ cellId,
+ FindBuildingCollisions(engine, cellId));
if (building != TransitionState.OK)
return building;
TransitionState objects = ObservePrimaryCellPhase(
engine,
TransitionCellCollisionPhase.Objects,
- currentCellId,
- FindObjCollisionsInCell(engine, currentCellId));
+ cellId,
+ FindObjCollisionsInCell(engine, cellId));
DumpPhase2(innerAttempt, environment, objects);
return objects;
}
@@ -3125,7 +3124,9 @@ public sealed class Transition
}
}
- internal TransitionState FindEnvCollisions(PhysicsEngine engine)
+ internal TransitionState FindEnvCollisions(
+ PhysicsEngine engine,
+ uint primaryCellId)
{
var sp = SpherePath;
var ci = CollisionInfo;
@@ -3153,23 +3154,19 @@ public sealed class Transition
// first (the indoor BSP block / the terrain block below), then advance the cell only in the
// post-collision step (RunCheckOtherCellsAndAdvance) — retail's collide-then-pick order.
//
- // Cache-null fallback: PhysicsEngineTests use engines without a DataCache (no cell registry,
- // so FindCellSet is unavailable). Keep the old outdoor re-derive for them only.
- if (engine.DataCache is null)
- {
- uint resolvedOutdoorCellId = engine.ResolveCellId(sp.GlobalSphere[0].Origin, sphereRadius, sp.CheckCellId);
- if (resolvedOutdoorCellId != sp.CheckCellId)
- sp.SetCheckPos(sp.CheckPos, resolvedOutdoorCellId);
- }
-
+ // insert_into_cell receives one CObjCell* from transitional_insert
+ // and invokes that same object's virtual find_collisions method for
+ // every inner retry. primaryCellId is therefore deliberately fixed
+ // even if a response mutates sphere_path.check_cell; only the next
+ // outer transitional_insert attempt captures the replacement cell.
// ── Indoor cell BSP collision ────────────────────────────────────
// If the player is in an indoor cell (low 16 bits >= 0x0100),
// query the CellStruct's PhysicsBSP for wall/floor/ceiling collision.
// ACE: EnvCell.find_env_collisions -> CellStructure.PhysicsBSP.find_collisions
- uint cellLow = sp.CheckCellId & 0xFFFFu;
+ uint cellLow = primaryCellId & 0xFFFFu;
if (cellLow >= 0x0100 && engine.DataCache is not null)
{
- var cellPhysics = engine.DataCache.GetCellStruct(sp.CheckCellId);
+ var cellPhysics = engine.DataCache.GetCellStruct(primaryCellId);
// AP-71 (Campaign P Slice P4, 2026-07-30): retail CEnvCell::
// find_env_collisions (pc:309576) calls check_entry_restrictions
@@ -3241,7 +3238,7 @@ public sealed class Transition
// so we degrade to "translation-only" instead of the prior
// "both broken".
Console.WriteLine(System.FormattableString.Invariant(
- $"[indoor-bsp] WARN cellPhysics.WorldTransform did not decompose cleanly for cell 0x{sp.CheckCellId:X8} — falling back to identity rotation"));
+ $"[indoor-bsp] WARN cellPhysics.WorldTransform did not decompose cleanly for cell 0x{primaryCellId:X8} — falling back to identity rotation"));
cellRotation = Quaternion.Identity;
cellOrigin = cellPhysics.WorldTransform.Translation;
}
@@ -3270,7 +3267,7 @@ public sealed class Transition
: System.FormattableString.Invariant(
$"n=({hit.Plane.Normal.X:F3},{hit.Plane.Normal.Y:F3},{hit.Plane.Normal.Z:F3}) sides={hit.SidesType}");
Console.WriteLine(System.FormattableString.Invariant(
- $"[indoor-bsp] cell=0x{sp.CheckCellId:X8} wpos=({footCenter.X:F3},{footCenter.Y:F3},{footCenter.Z:F3}) lpos=({localCenter.X:F3},{localCenter.Y:F3},{localCenter.Z:F3}) lprev=({localCurrCenter.X:F3},{localCurrCenter.Y:F3},{localCurrCenter.Z:F3}) r={sphereRadius:F3} result={cellState} ")
+ $"[indoor-bsp] cell=0x{primaryCellId:X8} wpos=({footCenter.X:F3},{footCenter.Y:F3},{footCenter.Z:F3}) lpos=({localCenter.X:F3},{localCenter.Y:F3},{localCenter.Z:F3}) lprev=({localCurrCenter.X:F3},{localCurrCenter.Y:F3},{localCurrCenter.Z:F3}) r={sphereRadius:F3} result={cellState} ")
+ polyDesc);
}
@@ -3314,7 +3311,10 @@ public sealed class Transition
// Runs against the carried (outdoor) cell. The post-collision pick below then promotes
// to an interior cell if the sphere has re-entered a building (replacing the removed
// pre-pick's outdoor→indoor promotion).
- var terrainWalkable = engine.SampleTerrainWalkable(footCenter.X, footCenter.Y);
+ var terrainWalkable = engine.SampleTerrainWalkableInCell(
+ primaryCellId,
+ footCenter.X,
+ footCenter.Y);
if (terrainWalkable is not null)
{
// Per-point water depth: 0.9 on fully water cells, 0.45 on partial-
@@ -3355,7 +3355,21 @@ public sealed class Transition
PhysicsEngine engine, Vector3 footCenter, float sphereRadius)
{
var sp = SpherePath;
- if (engine.DataCache is null) return TransitionState.OK;
+
+ // Test-only engines can omit the cell registry required for retail's
+ // find_cell_list. Preserve their outdoor membership update here at
+ // the post-primary boundary, never from inside the fixed-cell
+ // insert_into_cell transaction.
+ if (engine.DataCache is null)
+ {
+ uint resolvedOutdoorCellId = engine.ResolveCellId(
+ sp.GlobalSphere[0].Origin,
+ sphereRadius,
+ sp.CheckCellId);
+ if (resolvedOutdoorCellId != sp.CheckCellId)
+ sp.SetCheckPos(sp.CheckPos, resolvedOutdoorCellId);
+ return TransitionState.OK;
+ }
// Retail check_other_cells (acclient_2013_pseudo_c.txt:272735) calls each
// other cell's find_collisions with `this`, so it reads the CURRENT
diff --git a/tests/AcDream.Core.Tests/Physics/IndoorContactPlaneRetentionTests.cs b/tests/AcDream.Core.Tests/Physics/IndoorContactPlaneRetentionTests.cs
index eeaf753e..a643df16 100644
--- a/tests/AcDream.Core.Tests/Physics/IndoorContactPlaneRetentionTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/IndoorContactPlaneRetentionTests.cs
@@ -292,7 +292,7 @@ public class IndoorContactPlaneRetentionTests
t.SpherePath.SetCheckPos(newPos, IndoorCellId);
// Simulate FindEnvCollisions as the physics loop calls it.
- t.FindEnvCollisions(engine);
+ t.FindEnvCollisions(engine, IndoorCellId);
}
// ── Assert ────────────────────────────────────────────────────────────
diff --git a/tests/AcDream.Core.Tests/Physics/TransitionInsertIntoCellRetryTests.cs b/tests/AcDream.Core.Tests/Physics/TransitionInsertIntoCellRetryTests.cs
index b97985c1..446871ef 100644
--- a/tests/AcDream.Core.Tests/Physics/TransitionInsertIntoCellRetryTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/TransitionInsertIntoCellRetryTests.cs
@@ -190,39 +190,68 @@ public sealed class TransitionInsertIntoCellRetryTests
}
[Fact]
- public void NoDataCache_CrossLandblockRepick_UsesRefreshedPrimaryCell()
+ public void NestedRetry_FixesPrimaryCellUntilNextOuterAttempt()
{
- PhysicsEngine engine = BuildCrossLandblockEngine();
+ PhysicsEngine engine = BuildEngine(preparedFlat: false);
var cells = new List();
var phases = new List();
+ int environmentCalls = 0;
+ const uint nextOuterCell = 0xA9B40002u;
engine.TransitionCellCollisionTestHook =
- (_, phase, cellId, actual) =>
+ (transition, phase, cellId, actual) =>
{
phases.Add(phase);
cells.Add(cellId);
+
+ if (phase == TransitionCellCollisionPhase.Environment)
+ {
+ environmentCalls++;
+ if (environmentCalls == 1)
+ {
+ transition.SpherePath.SetCheckPos(
+ transition.SpherePath.CheckPos,
+ nextOuterCell);
+ }
+
+ if (environmentCalls <= 3)
+ return TransitionState.Adjusted;
+ }
+
return actual;
};
- Vector3 current = new(191.99f, 10f, 5f);
- Vector3 target = new(192.01f, 10f, 5f);
+ Vector3 current = new(10f, 10f, 5f);
+ Vector3 target = current + new Vector3(0.05f, 0f, 0f);
ResolveResult result = ResolveAirborne(
engine,
current,
target,
- 0xA9B40039u);
+ Cell);
Assert.True(result.Ok);
Assert.Equal(target, result.Position);
- Assert.Equal(0xAAB40001u, result.CellId);
Assert.Equal(
new[]
{
+ TransitionCellCollisionPhase.Environment,
+ TransitionCellCollisionPhase.Environment,
+ TransitionCellCollisionPhase.Environment,
TransitionCellCollisionPhase.Environment,
TransitionCellCollisionPhase.Building,
TransitionCellCollisionPhase.Objects,
},
phases);
- Assert.All(cells, cellId => Assert.Equal(0xAAB40001u, cellId));
+ Assert.Equal(
+ new[]
+ {
+ Cell,
+ Cell,
+ Cell,
+ nextOuterCell,
+ nextOuterCell,
+ nextOuterCell,
+ },
+ cells);
}
private static ResolveResult ResolveAirborne(
@@ -309,26 +338,4 @@ public sealed class TransitionInsertIntoCellRetryTests
return engine;
}
- private static PhysicsEngine BuildCrossLandblockEngine()
- {
- var engine = new PhysicsEngine { DataCache = null };
- var heights = new byte[81];
- var heightTable = new float[256];
- Array.Fill(heightTable, -1000f);
- engine.AddLandblock(
- 0xA9B4FFFFu,
- new TerrainSurface(heights, heightTable),
- Array.Empty(),
- Array.Empty(),
- 0f,
- 0f);
- engine.AddLandblock(
- 0xAAB4FFFFu,
- new TerrainSurface(heights, heightTable),
- Array.Empty(),
- Array.Empty(),
- 192f,
- 0f);
- return engine;
- }
}