fix(physics): restore nested per-cell collision retries

This commit is contained in:
Erik 2026-07-31 12:25:03 +02:00
parent 10b55d7485
commit e5f855ac40
3 changed files with 292 additions and 71 deletions

View file

@ -63,6 +63,18 @@ public sealed class PhysicsEngine
/// </summary>
public Action<string>? DiagnosticLog { get; set; }
/// <summary>
/// Deterministic test seam for the retail per-cell dispatcher. Production
/// leaves this null. Tests may observe a completed phase and substitute its
/// returned state to prove retry/order semantics without geometry-specific
/// response coupling.
/// </summary>
internal Func<
TransitionCellCollisionPhase,
uint,
TransitionState,
TransitionState>? TransitionCellCollisionTestHook { get; set; }
/// <summary>
/// True once the landblock covering <paramref name="cellOrLandblockId"/> has had its
/// terrain + cells registered via <see cref="AddLandblock"/>. Accepts a canonical

View file

@ -15,6 +15,18 @@ public enum TransitionState
Slid = 4,
}
/// <summary>
/// Test-observation boundary for retail's atomic per-cell collision pass.
/// Production never installs the corresponding hook on
/// <see cref="PhysicsEngine"/>.
/// </summary>
internal enum TransitionCellCollisionPhase
{
Environment,
Building,
Objects,
}
public enum InsertType
{
Transition = 0,
@ -1685,14 +1697,18 @@ public sealed class Transition
// -----------------------------------------------------------------------
/// <summary>
/// ACE Transition.TransitionalInsert — retry loop for collision resolution.
/// Retail <c>CTransition::transitional_insert</c> (0x0050B6F0) — the
/// outer transition retry loop. Each outer attempt delegates the complete
/// primary-cell transaction to <see cref="InsertIntoCell"/>, which owns a
/// second retry budget matching <c>CTransition::insert_into_cell</c>
/// (0x00509E70).
///
/// <para>
/// Per ACE: iterate up to numAttempts times. Each iteration runs the full
/// collision pipeline (env + objects) at the current CheckPos. The pipeline
/// can MUTATE CheckPos (push-out, slide). On Slid/Adjusted, clear state and
/// retry — the next iteration tests the NEW CheckPos against all nearby
/// objects again, which catches "slide into a second wall" corner cases.
/// Each outer attempt asks <see cref="InsertIntoCell"/> to retry the atomic
/// environment → building → objects composition up to
/// <paramref name="numAttempts"/> times. An Adjusted/Slid result that
/// exhausts that inner budget is then eligible for another outer attempt.
/// Retail therefore permits up to N×N primary-cell passes, not N total.
/// </para>
///
/// <para>
@ -1730,18 +1746,21 @@ public sealed class Transition
for (int attempt = 0; attempt < numAttempts; attempt++)
{
// ── Phase 1: environment collision (terrain + indoor BSP) ───
// Primary cell only — retail CEnvCell/CLandCell::find_collisions
// step 1 (find_env_collisions). Other cells run in Phase 2.5.
transitState = FindEnvCollisions(engine);
transitState = InsertIntoCell(
engine,
sp.CheckCellId,
numAttempts);
if (transitState == TransitionState.Collided)
{
sp.NegPolyHit = false;
return TransitionState.Collided;
}
if (transitState == TransitionState.Slid)
{
// Env collision slid the sphere. Clear state and retry at
// the new CheckPos to see if we hit anything else.
// Retail transitional_insert repeats the Slid contact clear
// at its outer boundary, then clears neg_poly_hit.
ci.ContactPlaneValid = false;
ci.ContactPlaneIsWater = false;
sp.NegPolyHit = false;
@ -1750,70 +1769,15 @@ public sealed class Transition
if (transitState == TransitionState.Adjusted)
{
// Env modified CheckPos. Retry at new position.
// insert_into_cell exhausted its own retry budget. Preserve
// every sphere field except neg_poly_hit at this outer
// boundary and start the next outer attempt.
sp.NegPolyHit = false;
continue;
}
// ── Phase 1b: the building channel (BR-7 / A6.P4) ───────────
// CLandCell::find_collisions (Ghidra 0x00532d60) interposes
// CSortCell::find_collisions (0x005340a0 — the per-LandCell
// building shell BSP) between env and objects. Indoor primary
// cells have no building leg (CEnvCell::find_collisions,
// 0x0052c100). No-op when the cell has no building.
var bldgState = FindBuildingCollisions(engine, sp.CheckCellId);
if (bldgState == TransitionState.Collided)
return TransitionState.Collided;
if (bldgState == TransitionState.Slid)
{
transitState = bldgState;
ci.ContactPlaneValid = false;
ci.ContactPlaneIsWater = false;
sp.NegPolyHit = false;
if (transitState != TransitionState.OK)
continue;
}
if (bldgState == TransitionState.Adjusted)
{
transitState = bldgState;
sp.NegPolyHit = false;
continue;
}
// ── Phase 2: object collision — PRIMARY cell's shadow list ──
// Retail CObjCell::find_obj_collisions(this) (0x0052b750), the
// tail of the primary cell's find_collisions. Other cells'
// lists run per cell in Phase 2.5 (check_other_cells).
var objState = FindObjCollisionsInCell(engine, sp.CheckCellId);
// L.4-diag: log Phase outcomes per attempt so we can see whether
// we're escaping to the step-down branch or churning in retries.
DumpPhase2(attempt, transitState, objState);
if (objState == TransitionState.Collided)
return TransitionState.Collided;
if (objState == TransitionState.Slid)
{
// Object collision applied a push-out and set sliding normal.
// Retry at the new CheckPos — we may have slid into another
// object, or need to re-verify env at the new position.
transitState = objState;
ci.ContactPlaneValid = false;
ci.ContactPlaneIsWater = false;
sp.NegPolyHit = false;
continue;
}
if (objState == TransitionState.Adjusted)
{
// Object modified CheckPos (e.g. PerfectClip adjust_to_plane).
// Retry at the new position.
transitState = objState;
sp.NegPolyHit = false;
continue;
}
// ── Phase 2.5: other cells + carried-cell advance ────────────
// Retail transitional_insert OK_TS case (0x0050b756): on a clean
@ -2151,6 +2115,82 @@ public sealed class Transition
return transitState;
}
/// <summary>
/// Retail <c>CTransition::insert_into_cell</c> (0x00509E70). The cell's
/// virtual <c>find_collisions</c> call is one atomic environment →
/// building → objects transaction. Adjusted retries the entire
/// transaction. Slid additionally clears only contact-plane validity and
/// water state before retrying. OK and Collided return immediately.
/// </summary>
private TransitionState InsertIntoCell(
PhysicsEngine engine,
uint cellId,
int numAttempts)
{
if (cellId == 0)
return TransitionState.Collided;
TransitionState state = TransitionState.OK;
for (int attempt = 0; attempt < numAttempts; attempt++)
{
state = FindPrimaryCellCollisions(engine, cellId, attempt);
if (state is TransitionState.OK or TransitionState.Collided)
return state;
if (state == TransitionState.Slid)
{
CollisionInfo.ContactPlaneValid = false;
CollisionInfo.ContactPlaneIsWater = false;
}
}
return state;
}
/// <summary>
/// Primary-cell virtual <c>find_collisions</c> composition. A non-OK
/// response terminates this pass, so an inner retry always restarts from
/// environment before revisiting the building and object channels.
/// </summary>
private TransitionState FindPrimaryCellCollisions(
PhysicsEngine engine,
uint cellId,
int innerAttempt)
{
TransitionState environment = ObservePrimaryCellPhase(
engine,
TransitionCellCollisionPhase.Environment,
cellId,
FindEnvCollisions(engine));
if (environment != TransitionState.OK)
return environment;
TransitionState building = ObservePrimaryCellPhase(
engine,
TransitionCellCollisionPhase.Building,
cellId,
FindBuildingCollisions(engine, cellId));
if (building != TransitionState.OK)
return building;
TransitionState objects = ObservePrimaryCellPhase(
engine,
TransitionCellCollisionPhase.Objects,
cellId,
FindObjCollisionsInCell(engine, cellId));
DumpPhase2(innerAttempt, environment, objects);
return objects;
}
private static TransitionState ObservePrimaryCellPhase(
PhysicsEngine engine,
TransitionCellCollisionPhase phase,
uint cellId,
TransitionState actual)
=> engine.TransitionCellCollisionTestHook is { } hook
? hook(phase, cellId, actual)
: actual;
private TransitionState EdgeSlideAfterStepDownFailed(
PhysicsEngine engine,
float stepDownHeight,