using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Physics;
using DatReaderWriter.Types;
using Xunit;
namespace AcDream.Core.Tests.Physics;
///
/// Pins retail's two-level collision retry structure:
/// transitional_insert(N) wraps insert_into_cell(N), and every
/// inner retry restarts the complete cell transaction in
/// environment → building → objects order.
///
public sealed class TransitionInsertIntoCellRetryTests
{
private const uint Landblock = 0xA9B40000u;
private const uint Cell = 0xA9B40001u;
private const uint ShellGfxObj = 0x0100F001u;
[Theory]
[InlineData(false)]
[InlineData(true)]
public void NestedRetry_RecomputesAtomicCellPipeline_AndExceedsOuterBudget(
bool preparedFlat)
{
PhysicsEngine engine = BuildEngine(preparedFlat);
var phases = new List();
int environmentCalls = 0;
int buildingCalls = 0;
int objectCalls = 0;
engine.TransitionCellCollisionTestHook = (_, phase, cellId, actual) =>
{
Assert.Equal(Cell, cellId);
Assert.Equal(TransitionState.OK, actual);
phases.Add(phase);
switch (phase)
{
case TransitionCellCollisionPhase.Environment:
environmentCalls++;
return environmentCalls == 1
? TransitionState.Adjusted
: TransitionState.OK;
case TransitionCellCollisionPhase.Building:
buildingCalls++;
return buildingCalls == 1
? TransitionState.Adjusted
: TransitionState.OK;
case TransitionCellCollisionPhase.Objects:
objectCalls++;
return objectCalls == 1
? TransitionState.Adjusted
: TransitionState.OK;
default:
throw new ArgumentOutOfRangeException(nameof(phase));
}
};
Vector3 current = new(10f, 10f, 5f);
Vector3 target = current + new Vector3(0.05f, 0f, 0f);
var body = new PhysicsBody
{
Position = current,
Orientation = Quaternion.Identity,
TransientState = TransientStateFlags.Active,
};
ResolveResult result = engine.ResolveWithTransition(
current,
target,
Cell,
sphereRadius: 0.48f,
sphereHeight: 1.835f,
stepUpHeight: 0.6f,
stepDownHeight: 1.5f,
isOnGround: false,
body,
moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
movingEntityId: 0x000F4243u);
Assert.True(result.Ok);
Assert.Equal(target, result.Position);
// The first inner budget of three passes stops successively at env,
// building, and objects. The second outer attempt starts a fourth
// complete pass and succeeds. A flattened N=3 loop cannot reach it.
TransitionCellCollisionPhase[] expected =
[
TransitionCellCollisionPhase.Environment,
TransitionCellCollisionPhase.Environment,
TransitionCellCollisionPhase.Building,
TransitionCellCollisionPhase.Environment,
TransitionCellCollisionPhase.Building,
TransitionCellCollisionPhase.Objects,
TransitionCellCollisionPhase.Environment,
TransitionCellCollisionPhase.Building,
TransitionCellCollisionPhase.Objects,
];
Assert.Equal(expected, phases);
Assert.Equal(4, environmentCalls);
Assert.Equal(3, buildingCalls);
Assert.Equal(2, objectCalls);
Assert.True(
environmentCalls > 3,
"The fixture must require more complete cell passes than the "
+ "outer N=3 budget while remaining inside retail's N×N budget.");
}
[Fact]
public void NestedRetry_AlwaysAdjusted_ExhaustsExactNByNBudget()
{
PhysicsEngine engine = BuildEngine(preparedFlat: false);
int calls = 0;
engine.TransitionCellCollisionTestHook =
(_, phase, cellId, actual) =>
{
Assert.Equal(TransitionCellCollisionPhase.Environment, phase);
Assert.Equal(Cell, cellId);
Assert.Equal(TransitionState.OK, actual);
calls++;
return TransitionState.Adjusted;
};
Vector3 current = new(10f, 10f, 5f);
Vector3 target = current + new Vector3(0.05f, 0f, 0f);
Transition transition = BSPStepUpFixtures.MakeAirborneTransition(
current,
target,
Cell);
transition.SpherePath.SetCheckPos(target, Cell);
TransitionState result = transition.TransitionalInsertForTest(3, engine);
Assert.Equal(TransitionState.Adjusted, result);
Assert.Equal(target, transition.SpherePath.CheckPos);
Assert.Equal(9, calls);
}
[Fact]
public void NestedRetry_SlidClearsInnerContact_ThenOuterNegPoly()
{
PhysicsEngine engine = BuildEngine(preparedFlat: false);
int environmentCalls = 0;
var contactPlane = new Plane(Vector3.UnitZ, -5f);
engine.TransitionCellCollisionTestHook =
(transition, phase, _, actual) =>
{
if (phase != TransitionCellCollisionPhase.Environment)
return actual;
environmentCalls++;
if (environmentCalls <= 3)
{
if (environmentCalls > 1)
{
Assert.False(transition.CollisionInfo.ContactPlaneValid);
Assert.False(transition.CollisionInfo.ContactPlaneIsWater);
Assert.True(transition.SpherePath.NegPolyHit);
}
transition.CollisionInfo.SetContactPlane(
contactPlane,
Cell,
isWater: true);
transition.SpherePath.NegPolyHit = true;
return TransitionState.Slid;
}
// Three Slid responses exhaust the first inner N=3 budget.
// Its final Slid clear removes contact/water; the outer
// transitional_insert boundary additionally removes neg-poly.
Assert.False(transition.CollisionInfo.ContactPlaneValid);
Assert.False(transition.CollisionInfo.ContactPlaneIsWater);
Assert.False(transition.SpherePath.NegPolyHit);
return actual;
};
Vector3 current = new(10f, 10f, 5f);
ResolveResult result = ResolveAirborne(
engine,
current,
current + new Vector3(0.05f, 0f, 0f),
Cell);
Assert.True(result.Ok);
Assert.Equal(4, environmentCalls);
}
[Fact]
public void NoDataCache_CrossLandblockRepick_UsesRefreshedPrimaryCell()
{
PhysicsEngine engine = BuildCrossLandblockEngine();
var cells = new List();
var phases = new List();
engine.TransitionCellCollisionTestHook =
(_, phase, cellId, actual) =>
{
phases.Add(phase);
cells.Add(cellId);
return actual;
};
Vector3 current = new(191.99f, 10f, 5f);
Vector3 target = new(192.01f, 10f, 5f);
ResolveResult result = ResolveAirborne(
engine,
current,
target,
0xA9B40039u);
Assert.True(result.Ok);
Assert.Equal(target, result.Position);
Assert.Equal(0xAAB40001u, result.CellId);
Assert.Equal(
new[]
{
TransitionCellCollisionPhase.Environment,
TransitionCellCollisionPhase.Building,
TransitionCellCollisionPhase.Objects,
},
phases);
Assert.All(cells, cellId => Assert.Equal(0xAAB40001u, cellId));
}
private static ResolveResult ResolveAirborne(
PhysicsEngine engine,
Vector3 current,
Vector3 target,
uint cellId)
{
var body = new PhysicsBody
{
Position = current,
Orientation = Quaternion.Identity,
TransientState = TransientStateFlags.Active,
};
return engine.ResolveWithTransition(
current,
target,
cellId,
sphereRadius: 0.48f,
sphereHeight: 1.835f,
stepUpHeight: 0.6f,
stepDownHeight: 1.5f,
isOnGround: false,
body,
moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
movingEntityId: 0x000F4243u);
}
private static PhysicsEngine BuildEngine(bool preparedFlat)
{
var (root, resolved) = BSPStepUpFixtures.FlatRoof();
var normalized = new Dictionary(resolved.Count);
foreach ((ushort id, ResolvedPolygon polygon) in resolved)
{
normalized.Add(id, new ResolvedPolygon
{
Id = id,
Vertices = polygon.Vertices,
Plane = polygon.Plane,
NumPoints = polygon.NumPoints,
SidesType = polygon.SidesType,
});
}
var physics = new GfxObjPhysics
{
SourceId = ShellGfxObj,
BSP = new PhysicsBSPTree { Root = root },
Resolved = normalized,
BoundingSphere = root.BoundingSphere,
};
var cache = new PhysicsDataCache();
if (preparedFlat)
{
cache.CollisionTraversalMode = CollisionTraversalMode.Flat;
cache.CacheGfxObj(
ShellGfxObj,
FlatCollisionAssetBuilder.FlattenGfxObj(physics));
}
else
{
cache.RegisterGfxObjForTest(ShellGfxObj, physics);
}
// The far-away shell makes the building channel execute its actual
// graph/flat traversal and return OK without affecting the mover.
cache.CacheBuilding(
Cell,
Array.Empty(),
Matrix4x4.CreateTranslation(100f, 100f, 100f),
ShellGfxObj);
var engine = new PhysicsEngine { DataCache = cache };
var heights = new byte[81];
var heightTable = new float[256];
Array.Fill(heightTable, -1000f);
engine.AddLandblock(
Landblock,
new TerrainSurface(heights, heightTable),
Array.Empty(),
Array.Empty(),
0f,
0f);
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;
}
}