using System; using System.Collections.Generic; using System.Numerics; using AcDream.Core.Physics; using Xunit; namespace AcDream.Core.Tests.Physics; public class PhysicsEngineTests { private static float[] LinearHeightTable() { var table = new float[256]; for (int i = 0; i < 256; i++) table[i] = i * 1.0f; return table; } private static byte[] FlatHeightmap(byte value = 50) { var heights = new byte[81]; Array.Fill(heights, value); return heights; } private PhysicsEngine MakeFlatEngine(float terrainZ = 50f) { var engine = new PhysicsEngine(); var terrain = new TerrainSurface(FlatHeightmap((byte)terrainZ), LinearHeightTable()); engine.AddLandblock(0xA9B4FFFFu, terrain, Array.Empty(), Array.Empty(), worldOffsetX: 0f, worldOffsetY: 0f); return engine; } [Fact] public void Resolve_FlatTerrain_ZMatchesTerrain() { var engine = MakeFlatEngine(terrainZ: 50f); var result = engine.Resolve( new Vector3(96f, 96f, 50f), cellId: 0x0001, delta: new Vector3(1f, 0f, 0f), stepUpHeight: 2f); Assert.Equal(50f, result.Position.Z, precision: 1); Assert.True(result.IsOnGround); } [Fact] public void Resolve_WalkUpSmallSlope_Accepted() { // Heights slope from 50 to 52 across X — small enough for step height. var heights = new byte[81]; for (int x = 0; x < 9; x++) for (int y = 0; y < 9; y++) heights[x * 9 + y] = (byte)(50 + x / 4); // gentle slope var engine = new PhysicsEngine(); var terrain = new TerrainSurface(heights, LinearHeightTable()); engine.AddLandblock(0xA9B4FFFFu, terrain, Array.Empty(), Array.Empty(), worldOffsetX: 0f, worldOffsetY: 0f); var result = engine.Resolve( new Vector3(48f, 96f, 50f), cellId: 0x0001, delta: new Vector3(48f, 0f, 0f), stepUpHeight: 5f); Assert.True(result.IsOnGround); Assert.True(result.Position.Z >= 50f); // moved uphill } [Fact] public void Resolve_StepUpExceedsHeight_MovementBlocked() { // Heights jump sharply: left half = 50, right half = 100. var heights = new byte[81]; for (int x = 0; x < 9; x++) for (int y = 0; y < 9; y++) heights[x * 9 + y] = (byte)(x < 5 ? 50 : 100); var engine = new PhysicsEngine(); var terrain = new TerrainSurface(heights, LinearHeightTable()); engine.AddLandblock(0xA9B4FFFFu, terrain, Array.Empty(), Array.Empty(), worldOffsetX: 0f, worldOffsetY: 0f); // Try to walk from the low side to the high side. var result = engine.Resolve( new Vector3(96f, 96f, 50f), cellId: 0x0001, delta: new Vector3(48f, 0f, 0f), stepUpHeight: 2f); // Movement should be blocked — Z delta (50→100) exceeds step height (2). Assert.Equal(96f, result.Position.X, precision: 1); // didn't move Assert.True(result.IsOnGround); } [Fact] public void Resolve_OutdoorThroughPortal_TransitionsToIndoor() { var engine = new PhysicsEngine(); var terrain = new TerrainSurface(FlatHeightmap(50), LinearHeightTable()); // A CellSurface for the indoor cell with floor at Z=50. var cellVerts = new Dictionary { [0] = new(40f, 40f, 50f), [1] = new(60f, 40f, 50f), [2] = new(60f, 60f, 50f), [3] = new(40f, 60f, 50f), }; var cellPolys = new List> { new() { 0, 1, 2, 3 } }; var cell = new CellSurface(0x0100, cellVerts, cellPolys); // A portal plane at X=45 (vertical plane facing +X). // OwnerCellId = 0x0100 (the indoor cell), TargetCellId = 0xFFFF (faces outdoor). // From outside, walking through this portal enters OwnerCellId. var portal = PortalPlane.FromVertices( new Vector3(45f, 40f, 45f), new Vector3(45f, 60f, 45f), new Vector3(45f, 60f, 55f), targetCellId: 0xFFFF, ownerCellId: 0x0100, flags: 0); engine.AddLandblock(0xA9B4FFFFu, terrain, new[] { cell }, new[] { portal }, worldOffsetX: 0f, worldOffsetY: 0f); // Walk from X=40 (outdoor) through X=45 (portal) to X=50 (indoor). var result = engine.Resolve( new Vector3(40f, 50f, 50f), cellId: 0x0001, delta: new Vector3(10f, 0f, 0f), stepUpHeight: 5f); // Should have transitioned to indoor cell 0x0100. Assert.Equal(0x0100u, result.CellId & 0xFFFFu); Assert.True(result.IsOnGround); } [Fact] public void Resolve_IndoorThroughExitPortal_TransitionsToOutdoor() { var engine = new PhysicsEngine(); var terrain = new TerrainSurface(FlatHeightmap(50), LinearHeightTable()); var cellVerts = new Dictionary { [0] = new(40f, 40f, 50f), [1] = new(60f, 40f, 50f), [2] = new(60f, 60f, 50f), [3] = new(40f, 60f, 50f), }; var cellPolys = new List> { new() { 0, 1, 2, 3 } }; var cell = new CellSurface(0x0100, cellVerts, cellPolys); // Same portal geometry — OwnerCellId = 0x0100, TargetCellId = 0xFFFF (outdoor exit). var portal = PortalPlane.FromVertices( new Vector3(45f, 40f, 45f), new Vector3(45f, 60f, 45f), new Vector3(45f, 60f, 55f), targetCellId: 0xFFFF, ownerCellId: 0x0100, flags: 0); engine.AddLandblock(0xA9B4FFFFu, terrain, new[] { cell }, new[] { portal }, worldOffsetX: 0f, worldOffsetY: 0f); // Walk from X=50 (indoor) through X=45 (portal) to X=40 (outdoor). var result = engine.Resolve( new Vector3(50f, 50f, 50f), cellId: 0x0100, delta: new Vector3(-10f, 0f, 0f), stepUpHeight: 5f); // Should have transitioned to outdoor. Assert.True((result.CellId & 0xFFFFu) < 0x0100u); Assert.True(result.IsOnGround); } [Fact] public void Resolve_LandblockBoundary_PicksAdjacentTerrain() { var engine = new PhysicsEngine(); // Landblock A: flat at Z=50, offset at X=0. var terrainA = new TerrainSurface(FlatHeightmap(50), LinearHeightTable()); engine.AddLandblock(0xA9B4FFFFu, terrainA, Array.Empty(), Array.Empty(), worldOffsetX: 0f, worldOffsetY: 0f); // Landblock B: flat at Z=60, offset at X=192 (adjacent east). var terrainB = new TerrainSurface(FlatHeightmap(60), LinearHeightTable()); engine.AddLandblock(0xAAB4FFFFu, terrainB, Array.Empty(), Array.Empty(), worldOffsetX: 192f, worldOffsetY: 0f); // Walk from X=190 (landblock A) across to X=194 (landblock B). var result = engine.Resolve( new Vector3(190f, 96f, 50f), cellId: 0x0001, delta: new Vector3(4f, 0f, 0f), stepUpHeight: 15f); // Should be at Z=60 (landblock B's terrain) and position X≈194. Assert.Equal(60f, result.Position.Z, precision: 1); Assert.True(result.Position.X > 192f); } [Fact] public void ResolveWithTransition_OutdoorCellBoundary_UpdatesLowCellId() { var engine = MakeFlatEngine(terrainZ: 50f); var result = engine.ResolveWithTransition( currentPos: new Vector3(23f, 10f, 50f), targetPos: new Vector3(25f, 10f, 50f), cellId: 0x0001u, sphereRadius: 0.5f, sphereHeight: 1.2f, stepUpHeight: 0.4f, stepDownHeight: 0.4f, isOnGround: true); Assert.True(result.IsOnGround); Assert.InRange(result.Position.X, 24.9f, 25.1f); Assert.Equal(0x0009u, result.CellId); } [Fact] public void ResolveWithTransition_EdgeSlideFlag_AllowsNormalFlatMovement() { var engine = MakeFlatEngine(terrainZ: 50f); var result = engine.ResolveWithTransition( currentPos: new Vector3(96f, 96f, 50f), targetPos: new Vector3(98f, 96f, 50f), cellId: 0x0025u, sphereRadius: 0.5f, sphereHeight: 1.2f, stepUpHeight: 0.4f, stepDownHeight: 0.4f, isOnGround: true, moverFlags: ObjectInfoState.EdgeSlide); Assert.True(result.IsOnGround); Assert.InRange(result.Position.X, 97.9f, 98.1f); Assert.Equal(0x0025u, result.CellId); } [Fact] public void ResolveWithTransition_EdgeSlideStopsAtLoadedTerrainBoundary() { var engine = MakeFlatEngine(terrainZ: 50f); var body = new PhysicsBody { Position = new Vector3(191.25f, 96f, 50f), TransientState = TransientStateFlags.Contact | TransientStateFlags.OnWalkable, ContactPlaneValid = true, ContactPlane = new Plane(Vector3.UnitZ, -50f), ContactPlaneCellId = 0x003Du, }; var result = engine.ResolveWithTransition( currentPos: new Vector3(191.25f, 96f, 50f), targetPos: new Vector3(193f, 96f, 50f), cellId: 0x003Du, sphereRadius: 0.5f, sphereHeight: 1.2f, stepUpHeight: 0.4f, stepDownHeight: 0.4f, isOnGround: true, body: body, moverFlags: ObjectInfoState.EdgeSlide); Assert.True(result.IsOnGround); Assert.InRange(result.Position.X, 190.75f, 192.0001f); Assert.Equal(50f, result.Position.Z, precision: 2); } [Fact] public void ResolveWithTransition_EdgeSlideAtLoadedTerrainBoundary_PreservesTangentMotion() { var engine = MakeFlatEngine(terrainZ: 50f); var body = new PhysicsBody { Position = new Vector3(191f, 96f, 50f), TransientState = TransientStateFlags.Contact | TransientStateFlags.OnWalkable, ContactPlaneValid = true, ContactPlane = new Plane(Vector3.UnitZ, -50f), ContactPlaneCellId = 0x003Du, }; var settled = engine.ResolveWithTransition( currentPos: new Vector3(191f, 96f, 50f), targetPos: new Vector3(191.25f, 96f, 50f), cellId: 0x003Du, sphereRadius: 0.5f, sphereHeight: 1.2f, stepUpHeight: 0.4f, stepDownHeight: 0.4f, isOnGround: true, body: body, moverFlags: ObjectInfoState.EdgeSlide); Assert.True(body.WalkablePolygonValid); Assert.NotNull(body.WalkableVertices); var result = engine.ResolveWithTransition( currentPos: settled.Position, targetPos: new Vector3(193f, 98f, 50f), cellId: 0x003Du, sphereRadius: 0.5f, sphereHeight: 1.2f, stepUpHeight: 0.4f, stepDownHeight: 0.4f, isOnGround: true, body: body, moverFlags: ObjectInfoState.EdgeSlide); Assert.True(result.IsOnGround); Assert.InRange(result.Position.X, 190.75f, 192.0001f); Assert.True(result.Position.Y > 96.2f); Assert.Equal(50f, result.Position.Z, precision: 2); } [Fact] public void ResolveWithTransition_LandblockBoundary_UpdatesFullOutdoorCellId() { var engine = new PhysicsEngine(); var terrainA = new TerrainSurface(FlatHeightmap(50), LinearHeightTable()); engine.AddLandblock(0xA9B4FFFFu, terrainA, Array.Empty(), Array.Empty(), worldOffsetX: 0f, worldOffsetY: 0f); var terrainB = new TerrainSurface(FlatHeightmap(50), LinearHeightTable()); engine.AddLandblock(0xAAB4FFFFu, terrainB, Array.Empty(), Array.Empty(), worldOffsetX: 192f, worldOffsetY: 0f); var result = engine.ResolveWithTransition( currentPos: new Vector3(191f, 10f, 50f), targetPos: new Vector3(193f, 10f, 50f), cellId: 0xA9B40039u, sphereRadius: 0.5f, sphereHeight: 1.2f, stepUpHeight: 0.4f, stepDownHeight: 0.4f, isOnGround: true); Assert.True(result.IsOnGround); Assert.InRange(result.Position.X, 192.9f, 193.1f); Assert.Equal(0xAAB40001u, result.CellId); } [Fact] public void Resolve_LeaveIndoorCell_TransitionsToOutdoor() { var engine = new PhysicsEngine(); var terrain = new TerrainSurface(FlatHeightmap(50), LinearHeightTable()); var cellVerts = new Dictionary { [0] = new(40f, 40f, 55f), [1] = new(60f, 40f, 55f), [2] = new(60f, 60f, 55f), [3] = new(40f, 60f, 55f), }; var cellPolys = new List> { new() { 0, 1, 2, 3 } }; var cell = new CellSurface(0x0100, cellVerts, cellPolys); engine.AddLandblock(0xA9B4FFFFu, terrain, new[] { cell }, Array.Empty(), worldOffsetX: 0f, worldOffsetY: 0f); // Start inside the cell, walk out. var result = engine.Resolve( new Vector3(50f, 50f, 55f), cellId: 0x0100, delta: new Vector3(-20f, 0f, 0f), stepUpHeight: 10f); // Should transition back to outdoor. Assert.True(result.CellId < 0x0100u); Assert.Equal(50f, result.Position.Z, precision: 1); Assert.True(result.IsOnGround); } [Fact] public void Resolve_NoSurfaceUnderEntity_NotOnGround() { var engine = new PhysicsEngine(); // No landblocks loaded — entity is floating in void. var result = engine.Resolve( new Vector3(0f, 0f, 100f), cellId: 0x0001, delta: Vector3.Zero, stepUpHeight: 2f); Assert.False(result.IsOnGround); } /// /// #42 lock — when the moving entity's own ShadowEntry is registered /// in at the body's exact position /// (the production pattern from GameWindow.cs:2545 spawn → register /// + UpdatePosition live tracking), the airborne sweep MUST skip /// it. Without the gate, FindObjCollisions sees the cylinder as /// a foreign collidable and slides the sphere ~1m horizontally on the /// first non-zero-motion frame — the bug observed by the [SWEEP-OBJ] /// trace and reported as the post-jump XY drift in #42. /// /// Mirrors retail's self-skip at CObjCell::find_obj_collisions /// (named-retail acclient_2013_pseudo_c.txt:308931): /// physobj != arg2->object_info.object. /// /// [Fact] public void ResolveWithTransition_SelfShadowEntry_NotPushedWhenIdMatches() { var engine = MakeFlatEngine(terrainZ: 50f); // FindObjCollisions early-returns when DataCache is null. An empty // cache is enough for cylinder objects; only BSP objects look up // entries inside. engine.DataCache = new PhysicsDataCache(); const uint movingEntityId = 0xDEADBEEFu; var bodyPos = new Vector3(96f, 96f, 50f); var targetPos = bodyPos + new Vector3(0f, 0f, 0.022f); // stationary +Z // Register the moving entity's own ShadowEntry — humanoid Cylinder // sized to match the live-spawn registration in production // (GameWindow.cs:2545). The gfxObj id 0x02000001 is the standard // human setup; radius/height match the [SWEEP-OBJ] trace observed // during run #2 of the #42 investigation. engine.ShadowObjects.Register( entityId: movingEntityId, gfxObjId: 0x02000001u, worldPos: bodyPos, rotation: Quaternion.Identity, radius: 0.679f, worldOffsetX: 0f, worldOffsetY: 0f, landblockId: 0xA9B4FFFFu, collisionType: ShadowCollisionType.Cylinder, cylHeight: 1.835f); // Without the gate (movingEntityId == 0): the sweep must self-push. // This proves the registry actually causes a collision, so the // following filtered case is not a vacuous pass. var unfiltered = engine.ResolveWithTransition( currentPos: bodyPos, targetPos: targetPos, cellId: 0xA9B40039u, sphereRadius: 0.48f, sphereHeight: 1.2f, stepUpHeight: 0.4f, stepDownHeight: 0.4f, isOnGround: false, movingEntityId: 0u); float unfilteredXY = MathF.Sqrt( (unfiltered.Position.X - targetPos.X) * (unfiltered.Position.X - targetPos.X) + (unfiltered.Position.Y - targetPos.Y) * (unfiltered.Position.Y - targetPos.Y)); Assert.True(unfilteredXY > 0.5f, $"Without movingEntityId, sweep should self-push (got XY drift {unfilteredXY:F3}m)"); // With the gate: the sweep must leave XY unchanged. var filtered = engine.ResolveWithTransition( currentPos: bodyPos, targetPos: targetPos, cellId: 0xA9B40039u, sphereRadius: 0.48f, sphereHeight: 1.2f, stepUpHeight: 0.4f, stepDownHeight: 0.4f, isOnGround: false, movingEntityId: movingEntityId); float filteredXY = MathF.Sqrt( (filtered.Position.X - targetPos.X) * (filtered.Position.X - targetPos.X) + (filtered.Position.Y - targetPos.Y) * (filtered.Position.Y - targetPos.Y)); Assert.InRange(filteredXY, 0f, 0.001f); } }