fix(physics): hold retail cell across inner retries

This commit is contained in:
Erik 2026-07-31 12:40:50 +02:00
parent 67d1e9b331
commit 4ca7230b36
4 changed files with 168 additions and 83 deletions

View file

@ -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;
}
/// <summary>
/// Samples only the fixed outdoor cell supplied to retail
/// <c>CTransition::insert_into_cell</c>. The target point may move into a
/// neighboring cell during a retry, but retail continues dispatching the
/// captured <c>CObjCell*</c> until that inner call returns.
/// </summary>
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
}
/// <summary>
/// TEST-ONLY outdoor cell re-derive. The single caller is
/// <c>Transition.FindEnvCollisions</c>'s cache-null fallback
/// TEST-ONLY outdoor cell re-derive. The sole caller is
/// <c>Transition.RunCheckOtherCellsAndAdvance</c>'s cache-null fallback
/// (PhysicsEngineTests run engines without a <see cref="DataCache"/>,
/// so <see cref="CellTransit.FindCellSet"/> is unavailable). Production
/// membership flows exclusively through the collide-then-pick advance
/// so <see cref="CellTransit.FindCellSet"/> is unavailable). Normal
/// production membership flows exclusively through the collide-then-pick advance
/// (<c>RunCheckOtherCellsAndAdvance</c> → <c>FindCellSet</c>).
///
/// <para>

View file

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