acdream/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs
Erik 23adc9c9df fix(phys): #106 follow-up — legacy Resolve returns full prefixed cell ids (teleport bare-id wedge)
The #106 live gate run was sabotaged by a pre-existing bug the corrective
ACE teleport exposed: PhysicsEngine.Resolve (the legacy Phase-D resolve,
still used by the teleport-arrival snap at GameWindow.cs:4869 and the
player-mode-entry snap at :11295) returned BARE low-word cell ids on
every computed exit (ComputeOutdoorCellId, bestCell.CellId & 0xFFFF,
nextCellIndex, enterCellIndex). The teleport committed 0x0000013F into
PlayerMovementController.CellId, and a bare indoor id wedges the entire
membership chain:

- GetCellStruct(0x0000013F) misses (cells are keyed full-prefix) -> no
  indoor wall BSP -> walk through walls;
- the b3ce505 #98 gate reads "indoor primary" -> outdoor object radial
  sweep skipped -> NO object collision anywhere in the world;
- BuildCellSetAndPickContaining early-returns an unresolvable id forever
  (block 0x0000 is a real far-NW map block) -> membership frozen;
- render root never resolves -> interiors draw empty when stepping in.

Probe evidence: probe-cell-106-gate.log has exactly 2 [cell-transit]
lines for the whole session, both reason=teleport — the second one
(0xA9B3003C -> 0x0000013F) is the wedge. This is the L.2e "player CellId
tracked as bare low byte" finding (2026-05-12) finally biting; prefix
survival until now was a race artifact — Resolve only preserved the full
id when the landblock had not streamed in yet (passthrough exit), which
is why login snaps usually came out prefixed.

Fix: capture the matched landblock's key in Resolve's containment loop
and return lbPrefix | (targetCellId & 0xFFFF) on the computed exit —
the same full-32-bit convention Resolve's own doc comment states for
its inputs, and what both production callers (player snaps) require.
The passthrough exits (no landblock / step-reject) still return the
caller's id unchanged.

Tests: Resolve_IndoorStay_ReturnsFullPrefixedCellId (the teleport
shape, red pre-fix) + Resolve_OutdoorStay_ReturnsFullPrefixedCellId;
Resolve_LeaveIndoorCell_TransitionsToOutdoor's unmasked
`CellId < 0x100` assertion codified the bare behavior — now masked +
asserts the prefix. Full suite: 294+218+420 green; Core 1371 green +
the same 4 pre-existing door/#99-era failures + 1 skip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 23:34:30 +02:00

515 lines
20 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);
// Phase D fix: ResolveOutdoorCellId now always applies the matched
// landblock's high-16 prefix — 0xA9B4 prefix from the registered
// landblock (0xA9B4FFFF) is now included in the returned CellId.
Assert.Equal(0xA9B40009u, 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);
// Phase D fix: ResolveOutdoorCellId now always applies the matched
// landblock's high-16 prefix — 0xA9B4 prefix from the registered
// landblock (0xA9B4FFFF) is now included in the returned CellId.
Assert.Equal(0xA9B40025u, 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. (#106 follow-up: masked compare —
// Resolve now returns FULL prefixed cell ids; the old unmasked
// `CellId < 0x0100` assertion codified the bare-low-word bug.)
Assert.True((result.CellId & 0xFFFFu) < 0x0100u);
Assert.Equal(0xA9B40000u, result.CellId & 0xFFFF0000u);
Assert.Equal(50f, result.Position.Z, precision: 1);
Assert.True(result.IsOnGround);
}
/// <summary>
/// #106 follow-up (2026-06-09): the live boundary-walk gate was sabotaged by
/// the teleport-arrival snap (GameWindow.cs:4869) receiving a BARE indoor
/// cell id from Resolve (`0x0000013F`). A bare indoor id wedges the whole
/// membership chain: GetCellStruct misses (no wall BSP), the #98 gate reads
/// "indoor primary" and skips the outdoor object sweep (no collision with
/// anything), and FindCellSet can never re-resolve a malformed id. Resolve
/// MUST return the matched landblock's full 32-bit cell id on every
/// computed exit — the same convention its own inputs use.
/// </summary>
[Fact]
public void Resolve_IndoorStay_ReturnsFullPrefixedCellId()
{
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);
// The teleport shape: full indoor cell id in, zero delta (pure snap).
var result = engine.Resolve(
new Vector3(50f, 50f, 55f), cellId: 0xA9B40100u, delta: Vector3.Zero,
stepUpHeight: 5f);
Assert.Equal(0xA9B40100u, result.CellId);
}
[Fact]
public void Resolve_OutdoorStay_ReturnsFullPrefixedCellId()
{
var engine = MakeFlatEngine(terrainZ: 50f);
var result = engine.Resolve(
new Vector3(96f, 96f, 50f), cellId: 0xA9B40029u, delta: new Vector3(1f, 0f, 0f),
stepUpHeight: 2f);
// (97, 96) is over grid (4, 4) → low = 4*8+4+1 = 0x25, prefixed.
Assert.Equal(0xA9B40025u, result.CellId);
}
[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);
}
/// <summary>
/// #42 lock — when the moving entity's own ShadowEntry is registered
/// in <see cref="ShadowObjectRegistry"/> at the body's exact position
/// (the production pattern from <c>GameWindow.cs:2545</c> spawn → register
/// + <c>UpdatePosition</c> live tracking), the airborne sweep MUST skip
/// it. Without the gate, <c>FindObjCollisions</c> 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.
/// <para>
/// Mirrors retail's self-skip at <c>CObjCell::find_obj_collisions</c>
/// (named-retail <c>acclient_2013_pseudo_c.txt:308931</c>):
/// <c>physobj != arg2->object_info.object</c>.
/// </para>
/// </summary>
[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);
}
}