fix(physics): hold retail cell across inner retries
This commit is contained in:
parent
67d1e9b331
commit
4ca7230b36
4 changed files with 168 additions and 83 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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<uint>();
|
||||
var phases = new List<TransitionCellCollisionPhase>();
|
||||
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<CellSurface>(),
|
||||
Array.Empty<PortalPlane>(),
|
||||
0f,
|
||||
0f);
|
||||
engine.AddLandblock(
|
||||
0xAAB4FFFFu,
|
||||
new TerrainSurface(heights, heightTable),
|
||||
Array.Empty<CellSurface>(),
|
||||
Array.Empty<PortalPlane>(),
|
||||
192f,
|
||||
0f);
|
||||
return engine;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue