Retain building and object collision for available neighboring landcells when the terrain polygon does not cover the sphere center. Verified against named and paired retail dispatch. Add prepared-flat regressions and installed cathedral repro with sabotage proof. Full Release gate 17036/0/0 and graphical wall blocking, overlap correction and escape pass provisionally. Owner accepts functional collision; exact retail settle coordinates are not claimed. Retire UN-10; keep AP159, AMD stability and final G4 open.
330 lines
14 KiB
C#
330 lines
14 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Numerics;
|
|
using DatReaderWriter.Enums;
|
|
using DatReaderWriter.Types;
|
|
using AcDream.Core.Physics;
|
|
using Xunit;
|
|
|
|
namespace AcDream.Core.Tests.Physics;
|
|
|
|
/// <summary>
|
|
/// Pins the retail halt semantics of <see cref="Transition.CheckOtherCells"/>
|
|
/// (0x0050ae50), plus the available outdoor cell's terrain → building →
|
|
/// objects dispatch (CLandCell::find_collisions 0x00532d60). Neighbor fixtures
|
|
/// use prepared-flat production traversal with no replacement result hooks.
|
|
/// </summary>
|
|
public class TransitionCheckOtherCellsTests
|
|
{
|
|
private static Transition MakeTransition(bool contactFlag = false)
|
|
{
|
|
var t = new Transition();
|
|
t.SpherePath.InitPath(Vector3.Zero, Vector3.Zero, cellId: 0xA9B40100u, sphereRadius: 0.48f);
|
|
t.ObjectInfo.State = contactFlag ? ObjectInfoState.Contact : ObjectInfoState.None;
|
|
// Pre-set CP fields to non-default so the Slid-clears-CP assertion
|
|
// can detect the clear.
|
|
t.CollisionInfo.ContactPlaneValid = true;
|
|
t.CollisionInfo.ContactPlaneIsWater = true;
|
|
return t;
|
|
}
|
|
|
|
[Fact]
|
|
public void OK_ContinuesIteration_DoesNotMutate()
|
|
{
|
|
var t = MakeTransition();
|
|
|
|
bool halt = t.ApplyOtherCellResult(TransitionState.OK, out var finalState);
|
|
|
|
Assert.False(halt);
|
|
Assert.Equal(TransitionState.OK, finalState);
|
|
Assert.True(t.CollisionInfo.ContactPlaneValid);
|
|
Assert.True(t.CollisionInfo.ContactPlaneIsWater);
|
|
Assert.False(t.CollisionInfo.CollidedWithEnvironment);
|
|
}
|
|
|
|
[Fact]
|
|
public void Collided_HaltsAndSetsCollidedWithEnvironment_WhenNotInContact()
|
|
{
|
|
var t = MakeTransition(contactFlag: false);
|
|
|
|
bool halt = t.ApplyOtherCellResult(TransitionState.Collided, out var finalState);
|
|
|
|
Assert.True(halt);
|
|
Assert.Equal(TransitionState.Collided, finalState);
|
|
Assert.True(t.CollisionInfo.CollidedWithEnvironment);
|
|
}
|
|
|
|
[Fact]
|
|
public void Collided_DoesNotSetCollidedWithEnvironment_WhenInContact()
|
|
{
|
|
// Retail oracle gating: the CollidedWithEnvironment flip mirrors
|
|
// the existing primary-cell behavior in FindEnvCollisions —
|
|
// skipped when ObjectInfo.State has Contact bit set.
|
|
var t = MakeTransition(contactFlag: true);
|
|
|
|
bool halt = t.ApplyOtherCellResult(TransitionState.Collided, out var finalState);
|
|
|
|
Assert.True(halt);
|
|
Assert.Equal(TransitionState.Collided, finalState);
|
|
Assert.False(t.CollisionInfo.CollidedWithEnvironment);
|
|
}
|
|
|
|
[Fact]
|
|
public void Adjusted_HaltsAndSetsCollidedWithEnvironment_WhenNotInContact()
|
|
{
|
|
var t = MakeTransition(contactFlag: false);
|
|
|
|
bool halt = t.ApplyOtherCellResult(TransitionState.Adjusted, out var finalState);
|
|
|
|
Assert.True(halt);
|
|
Assert.Equal(TransitionState.Adjusted, finalState);
|
|
Assert.True(t.CollisionInfo.CollidedWithEnvironment);
|
|
}
|
|
|
|
[Fact]
|
|
public void Slid_HaltsAndClearsContactPlaneFields()
|
|
{
|
|
// Retail oracle: acclient_2013_pseudo_c.txt:272746-272750
|
|
// case 4:
|
|
// this->collision_info.contact_plane_valid = 0;
|
|
// this->collision_info.contact_plane_is_water = 0;
|
|
// return result;
|
|
var t = MakeTransition();
|
|
Assert.True(t.CollisionInfo.ContactPlaneValid); // pre-condition
|
|
Assert.True(t.CollisionInfo.ContactPlaneIsWater); // pre-condition
|
|
|
|
bool halt = t.ApplyOtherCellResult(TransitionState.Slid, out var finalState);
|
|
|
|
Assert.True(halt);
|
|
Assert.Equal(TransitionState.Slid, finalState);
|
|
Assert.False(t.CollisionInfo.ContactPlaneValid);
|
|
Assert.False(t.CollisionInfo.ContactPlaneIsWater);
|
|
}
|
|
|
|
[Fact]
|
|
public void CheckOtherCells_CellWithNullBspRoot_IsSkippedNoCrash()
|
|
{
|
|
// Iteration safety: a CellPhysics in the candidate set with
|
|
// `BSP = null` (loaded for render but not physics) must be skipped,
|
|
// not crash. Matches the spec's R2 guard at design §Edge cases E2.
|
|
var cell = new CellPhysics
|
|
{
|
|
BSP = null, // <-- the guard target
|
|
WorldTransform = Matrix4x4.Identity,
|
|
InverseWorldTransform = Matrix4x4.Identity,
|
|
Resolved = new Dictionary<ushort, ResolvedPolygon>(),
|
|
};
|
|
|
|
var engine = new PhysicsEngine();
|
|
engine.DataCache = new PhysicsDataCache(); // PhysicsEngine has nullable DataCache
|
|
// FindEnvCollisions has terrain probes downstream; populate a
|
|
// minimal landblock so the cache + engine are coherent. The cell
|
|
// we test against doesn't need a real landblock entry.
|
|
var heights = new byte[81];
|
|
Array.Fill(heights, (byte)0);
|
|
var ht = new float[256];
|
|
for (int i = 0; i < 256; i++) ht[i] = i * 1.0f;
|
|
engine.AddLandblock(0xA9B4FFFFu, new TerrainSurface(heights, ht),
|
|
Array.Empty<CellSurface>(), Array.Empty<PortalPlane>(),
|
|
worldOffsetX: 0f, worldOffsetY: 0f);
|
|
engine.DataCache.RegisterCellStructForTest(0xA9B40157u, cell);
|
|
|
|
var t = MakeTransition();
|
|
var cellSet = new HashSet<uint> { 0xA9B40157u };
|
|
|
|
// Call CheckOtherCells directly via the internal seam.
|
|
var result = t.CheckOtherCells(engine, Vector3.Zero, 0.48f, cellSet);
|
|
|
|
Assert.Equal(TransitionState.OK, result);
|
|
}
|
|
|
|
[Fact]
|
|
public void CheckOtherCells_OutdoorLandcellCandidate_UsesTerrainWalkable()
|
|
{
|
|
var engine = new PhysicsEngine();
|
|
engine.DataCache = new PhysicsDataCache();
|
|
|
|
var heights = new byte[81];
|
|
Array.Fill(heights, (byte)0);
|
|
var ht = new float[256];
|
|
for (int i = 0; i < 256; i++) ht[i] = i * 1.0f;
|
|
engine.AddLandblock(0xA9B4FFFFu, new TerrainSurface(heights, ht),
|
|
Array.Empty<CellSurface>(), Array.Empty<PortalPlane>(),
|
|
worldOffsetX: 0f, worldOffsetY: 0f);
|
|
|
|
var t = MakeTransition(contactFlag: true);
|
|
t.SpherePath.InitPath(new Vector3(10f, 10f, -0.28f), new Vector3(10f, 10f, -0.28f),
|
|
cellId: 0xA9B40147u, sphereRadius: 0.48f);
|
|
|
|
var footCenter = new Vector3(10f, 10f, 0.20f);
|
|
var cellSet = new HashSet<uint> { 0xA9B40001u };
|
|
|
|
var result = t.CheckOtherCells(engine, footCenter, 0.48f, cellSet);
|
|
|
|
Assert.Equal(TransitionState.Adjusted, result);
|
|
Assert.True(t.CollisionInfo.ContactPlaneValid);
|
|
Assert.Equal(0xA9B40001u, t.CollisionInfo.ContactPlaneCellId);
|
|
Assert.True(t.SpherePath.CheckPos.Z > -0.28f);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(false)]
|
|
[InlineData(true)]
|
|
public void OutdoorTerrainMiss_RetainsPreparedBuildingAndObjectCollision(bool objectChannel)
|
|
{
|
|
PhysicsEngine engine = NeighborWall(objectChannel);
|
|
Transition transition = NeighborTransition();
|
|
Assert.NotNull(engine.DataCache!.CellGraph.GetVisible(NeighborCell));
|
|
Assert.Null(engine.SampleTerrainWalkableInCell(NeighborCell, 23.8f, 12f));
|
|
|
|
TransitionState result = transition.CheckOtherCells(engine,
|
|
transition.SpherePath.GlobalSphere[0].Origin, 0.48f, new[] { NeighborCell });
|
|
|
|
// Actual flat BSP Path 6 calls SetCollide and returns Adjusted for
|
|
// an unclipped airborne sphere; its normal belongs to StepUpNormal.
|
|
Assert.Equal(TransitionState.Adjusted, result);
|
|
Assert.True(transition.SpherePath.Collide);
|
|
Assert.Equal(-Vector3.UnitX, transition.SpherePath.StepUpNormal);
|
|
if (objectChannel)
|
|
Assert.Equal(NeighborObject, transition.CollisionInfo.LastCollidedObjectGuid);
|
|
else
|
|
Assert.True(transition.CollisionInfo.CollidedWithEnvironment);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(false)]
|
|
[InlineData(true)]
|
|
public void OutdoorTerrainMiss_NonintersectingPreparedNeighborRemainsClear(bool objectChannel)
|
|
{
|
|
PhysicsEngine engine = NeighborWall(objectChannel, wallOriginX: 30f);
|
|
Transition transition = NeighborTransition();
|
|
Vector3 target = transition.SpherePath.CheckPos;
|
|
|
|
TransitionState result = transition.CheckOtherCells(engine,
|
|
transition.SpherePath.GlobalSphere[0].Origin, 0.48f, new[] { NeighborCell });
|
|
|
|
Assert.Equal(TransitionState.OK, result);
|
|
Assert.Equal(target, transition.SpherePath.CheckPos);
|
|
Assert.False(transition.CollisionInfo.CollisionNormalValid);
|
|
Assert.Null(transition.CollisionInfo.LastCollidedObjectGuid);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(false)]
|
|
[InlineData(true)]
|
|
public void UnavailableOutdoorCell_DoesNotDispatchRetainedBuildingOrObjects(bool objectChannel)
|
|
{
|
|
PhysicsEngine engine = NeighborWall(objectChannel, wallOriginX: 24f);
|
|
Transition available = NeighborTransition(targetX: 24.3f);
|
|
Assert.Equal(NeighborCell, engine.SampleTerrainWalkable(24.3f, 12f)!.Value.CellId);
|
|
Assert.Equal(TransitionState.Adjusted, available.CheckOtherCells(engine,
|
|
available.SpherePath.GlobalSphere[0].Origin, 0.48f, new[] { NeighborCell }));
|
|
// Deliberately leave stale cached geometry and engine terrain behind:
|
|
// CellGraph availability, not a terrain sample, governs dispatch.
|
|
engine.DataCache!.CellGraph.RemoveLandblock(NeighborCell);
|
|
Assert.Null(engine.DataCache.CellGraph.GetVisible(NeighborCell));
|
|
Assert.NotNull(engine.DataCache.GetGfxObj(NeighborGfx));
|
|
if (objectChannel)
|
|
Assert.NotEmpty(engine.ShadowObjects.GetObjectsInCell(NeighborCell));
|
|
else
|
|
Assert.NotNull(engine.DataCache.GetBuilding(NeighborCell));
|
|
Transition transition = NeighborTransition(targetX: 24.3f);
|
|
Vector3 target = transition.SpherePath.CheckPos;
|
|
|
|
TransitionState result = transition.CheckOtherCells(engine,
|
|
transition.SpherePath.GlobalSphere[0].Origin, 0.48f, new[] { NeighborCell });
|
|
|
|
Assert.Equal(TransitionState.OK, result);
|
|
Assert.Equal(target, transition.SpherePath.CheckPos);
|
|
Assert.False(transition.CollisionInfo.CollisionNormalValid);
|
|
Assert.Null(transition.CollisionInfo.LastCollidedObjectGuid);
|
|
}
|
|
|
|
[Fact]
|
|
public void OutdoorPrimaryCell_IsNotDispatchedAgain()
|
|
{
|
|
PhysicsEngine engine = NeighborWall(objectChannel: false);
|
|
Transition transition = NeighborTransition();
|
|
transition.SpherePath.SetCheckPos(transition.SpherePath.CheckPos, NeighborCell);
|
|
|
|
Assert.Equal(TransitionState.OK, transition.CheckOtherCells(engine,
|
|
transition.SpherePath.GlobalSphere[0].Origin, 0.48f, new[] { NeighborCell }));
|
|
Assert.False(transition.CollisionInfo.CollisionNormalValid);
|
|
}
|
|
|
|
[Fact]
|
|
public void NeighborBuildingNonOk_StopsBeforeNeighborObjects()
|
|
{
|
|
PhysicsEngine engine = NeighborWall(objectChannel: false);
|
|
engine.ShadowObjects.Register(NeighborObject, NeighborGfx, new Vector3(23.5f, 12f, 0f),
|
|
Quaternion.Identity, 10f, 0f, 0f, 0xA9B40000u,
|
|
collisionType: ShadowCollisionType.BSP, state: 0x00010000u,
|
|
seedCellId: NeighborCell, isStatic: true);
|
|
Assert.NotEmpty(engine.ShadowObjects.GetObjectsInCell(NeighborCell));
|
|
Transition transition = NeighborTransition();
|
|
|
|
Assert.Equal(TransitionState.Adjusted, transition.CheckOtherCells(engine,
|
|
transition.SpherePath.GlobalSphere[0].Origin, 0.48f, new[] { NeighborCell }));
|
|
Assert.True(transition.CollisionInfo.CollidedWithEnvironment);
|
|
Assert.Null(transition.CollisionInfo.LastCollidedObjectGuid);
|
|
}
|
|
|
|
private const uint NeighborCell = 0xA9B40009u;
|
|
private const uint NeighborGfx = 0x0100F482u;
|
|
private const uint NeighborObject = 0xCA9B4482u;
|
|
|
|
private static Transition NeighborTransition(float targetX = 23.8f)
|
|
{
|
|
Vector3 target = new(targetX, 12f, 1f);
|
|
var transition = new Transition();
|
|
transition.SpherePath.InitPath(new Vector3(targetX - 0.4f, 12f, 1f), target,
|
|
0xA9B40001u, 0.48f);
|
|
transition.SpherePath.SetCheckPos(target, 0xA9B40001u);
|
|
return transition;
|
|
}
|
|
|
|
private static PhysicsEngine NeighborWall(bool objectChannel, float wallOriginX = 23.5f)
|
|
{
|
|
var (root, polygons) = BSPStepUpFixtures.TallWall();
|
|
var normalized = new Dictionary<ushort, ResolvedPolygon>();
|
|
foreach ((ushort id, ResolvedPolygon polygon) in polygons)
|
|
normalized.Add(id, new ResolvedPolygon
|
|
{
|
|
Id = id, Vertices = polygon.Vertices, Plane = polygon.Plane,
|
|
NumPoints = polygon.NumPoints, SidesType = polygon.SidesType,
|
|
});
|
|
var graph = new GfxObjPhysics
|
|
{
|
|
SourceId = NeighborGfx,
|
|
BSP = new PhysicsBSPTree { Root = root },
|
|
Resolved = normalized,
|
|
BoundingSphere = root.BoundingSphere,
|
|
};
|
|
var cache = PhysicsDataCache.CreateProduction();
|
|
cache.CacheGfxObj(NeighborGfx, FlatCollisionAssetBuilder.FlattenGfxObj(graph));
|
|
Assert.Equal(CollisionTraversalMode.Flat, cache.CollisionTraversalMode);
|
|
Assert.Null(cache.GetGfxObj(NeighborGfx)!.BSP);
|
|
Assert.NotNull(cache.GetGfxObj(NeighborGfx)!.FlatPhysicsBsp);
|
|
var engine = new PhysicsEngine { DataCache = cache };
|
|
var heightTable = new float[256];
|
|
Array.Fill(heightTable, -1000f);
|
|
engine.AddLandblock(0xA9B4FFFFu, new TerrainSurface(new byte[81], heightTable),
|
|
Array.Empty<CellSurface>(), Array.Empty<PortalPlane>(), 0f, 0f);
|
|
Vector3 origin = new(wallOriginX, 12f, 0f);
|
|
if (objectChannel)
|
|
{
|
|
engine.ShadowObjects.Register(NeighborObject, NeighborGfx, origin,
|
|
Quaternion.Identity, 10f, 0f, 0f, 0xA9B40000u,
|
|
collisionType: ShadowCollisionType.BSP, state: 0x00010000u,
|
|
seedCellId: NeighborCell, isStatic: true);
|
|
Assert.Contains(engine.ShadowObjects.GetObjectsInCell(NeighborCell),
|
|
entry => entry.EntityId == NeighborObject);
|
|
}
|
|
else
|
|
{
|
|
cache.CacheBuilding(NeighborCell, Array.Empty<BldPortalInfo>(),
|
|
Matrix4x4.CreateTranslation(origin), NeighborGfx);
|
|
}
|
|
return engine;
|
|
}
|
|
}
|