Phase L.4 closes the "stuck in falling animation on a steep roof" bug
the user reported on 2026-04-30 ("I jump up, I land on it. It should not
even let me land, should just slide with a falling animation"). After
this commit the body no longer sticks to a steep roof when jumping
into it — it slides along the slope while keeping the falling animation.
Two pieces:
1. BSPQuery Path 6 steep-poly slide
When an airborne sphere hits a polygon whose world normal Z is below
FloorZ (≈ 0.6642, slope > ~49°), the previous flow was:
Path 6 SetCollide → Path 4 set_walkable → ContactPlane committed →
body "lands" on the steep poly with Contact bit + falling animation.
This left the player stuck mid-slope because OnWalkable was cleared
but Contact stayed set.
The new branch detects the steep normal in Path 6 BEFORE SetCollide
is called. Instead of entering the landing path, it removes the
into-wall component of the move (project onto the steep face), sets
CollisionNormal + SlidingNormal, and returns Slid. Same shape as
Path 5's step-up fallback and CylinderCollision. The resolver retries;
the sphere is now outside the poly; FindCollisions returns OK;
ValidateTransition commits the slid position. ContactPlane is never
set, so the body stays airborne with falling animation.
2. PlayerMovementController L.3a-bounce carve-out + Inelastic stop
Re-enables the velocity-reflection bounce when the contact normal is
upward-facing but steeper than walkable (0 < N.Z < FloorZ). The base
L.3a rule suppresses bounce on landing transitions to avoid micro-
bounce on flat terrain; that suppression also stuck the player to
too-steep roofs they shouldn't land on. This carve-out re-enables
the reflection specifically for the steep upward case.
Also lands related L.2c precipice / edge-slide work that was in flight:
- TransitionTypes EdgeSlideAfterStepDownFailed: walkable-poly-steep
cliff route + steep-ContactPlane cliff route ordering, so that
CliffSlide fires when the stored walkable polygon itself is too
steep (Path 4 had previously accepted it as a "landing" via the
permissive LandingZ threshold).
- CliffSlide reference-normal selection: prefer LastWalkable, fall back
to LastKnownContactPlane only when walkable, else use world-up. This
prevents the cross(steepN, steepN) = 0 degenerate case that left the
cliff slide as a no-op when both current and last-known were steep.
- Phase 2 / step-down branch / edge-slide branch / cliff-slide
diagnostic helpers gated on ACDREAM_DUMP_EDGE_SLIDE / ACDREAM_DUMP_STEEP_ROOF.
- Two new airborne-mover regression tests in BSPStepUpTests +
PhysicsEngineTests covering wall-slide and edge tangent motion.
DEVIATION FROM RETAIL — DOCUMENTED FOR FOLLOW-UP
The Path 6 steep slide is NOT what retail does. Retail's flow on the
same hit is:
Path 6 SetCollide (no steep check) → Path 4 find_walkable returns
nothing for steep → Phase 3 reset path: restore_check_pos +
kill_velocity → return COLLIDED → validate_transition reverts CheckPos
to CurPos and forces OK.
Net retail behavior: position reverts to pre-failed-move (typically
just below the roof in the common jump-up case), velocity zeroed,
gravity rebuilds Z next frame, body falls back down naturally with
the falling animation. The "freeze" framing I used earlier was wrong;
in the typical case retail just bounces the body off and lets gravity
take over.
Strict retail behavior would match the user's intent better in the
common case AND avoid the bounce-energy-accumulation we saw with the
slide-tangent approach (V grew to ~50 m/s in continuous-contact frames).
However, retail's behavior degenerates in the edge case of an overhead
landing onto a steep slope (body would freeze mid-air above the roof).
This commit ships the slide-tangent fix as an interim "much better"
state per user verification on 2026-04-30. Follow-up work to match
retail strictly: revert Path 6 steep-slide, audit Phase 3 reset to
ensure kill_velocity (matching OBJECTINFO::kill_velocity ->
CPhysicsObj::set_velocity({0,0,0}, 0)) actually fires, and re-test.
Refs:
- acclient_2013_pseudo_c.txt:323784-323821 (Path 6 SetCollide)
- acclient_2013_pseudo_c.txt:273191-273239 (Phase 3 reset path)
- acclient_2013_pseudo_c.txt:272563-272596 (validate_transition revert)
- acclient_2013_pseudo_c.txt:274467-274475 (kill_velocity)
- acclient_2013_pseudo_c.txt:282699-282715 (handle_all_collisions bounce)
Tests: 833/833 green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
380 lines
14 KiB
C#
380 lines
14 KiB
C#
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<CellSurface>(), Array.Empty<PortalPlane>(),
|
|
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<CellSurface>(), Array.Empty<PortalPlane>(),
|
|
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<CellSurface>(), Array.Empty<PortalPlane>(),
|
|
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<ushort, Vector3>
|
|
{
|
|
[0] = new(40f, 40f, 50f),
|
|
[1] = new(60f, 40f, 50f),
|
|
[2] = new(60f, 60f, 50f),
|
|
[3] = new(40f, 60f, 50f),
|
|
};
|
|
var cellPolys = new List<List<short>> { 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<ushort, Vector3>
|
|
{
|
|
[0] = new(40f, 40f, 50f),
|
|
[1] = new(60f, 40f, 50f),
|
|
[2] = new(60f, 60f, 50f),
|
|
[3] = new(40f, 60f, 50f),
|
|
};
|
|
var cellPolys = new List<List<short>> { 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<CellSurface>(),
|
|
Array.Empty<PortalPlane>(), 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<CellSurface>(),
|
|
Array.Empty<PortalPlane>(), 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<CellSurface>(),
|
|
Array.Empty<PortalPlane>(), worldOffsetX: 0f, worldOffsetY: 0f);
|
|
|
|
var terrainB = new TerrainSurface(FlatHeightmap(50), LinearHeightTable());
|
|
engine.AddLandblock(0xAAB4FFFFu, terrainB, Array.Empty<CellSurface>(),
|
|
Array.Empty<PortalPlane>(), 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<ushort, Vector3>
|
|
{
|
|
[0] = new(40f, 40f, 55f),
|
|
[1] = new(60f, 40f, 55f),
|
|
[2] = new(60f, 60f, 55f),
|
|
[3] = new(40f, 60f, 55f),
|
|
};
|
|
var cellPolys = new List<List<short>> { new() { 0, 1, 2, 3 } };
|
|
var cell = new CellSurface(0x0100, cellVerts, cellPolys);
|
|
|
|
engine.AddLandblock(0xA9B4FFFFu, terrain, new[] { cell }, Array.Empty<PortalPlane>(),
|
|
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);
|
|
}
|
|
}
|