The Holtburg town-network portal platform (stab 0xC0A9B465, Setup 0x020019E3, CylSphere r=2.597m h=0.256m) blocked the player with an endless rim slide instead of retail's step-up-onto-top — gating the whole #137 dungeon repro. Surfaced when #149 started registering BSP-less stab CylSpheres: the collision SHAPE became right while the RESPONSE was still the hand-rolled AP-6 approximation (step-up gate + radial wall-slide only). Root cause: no cylinder-TOP support anywhere. DoStepUp's internal step-down probe needs retail's step_sphere_down (0x0053a9b0) to land on the flat top — a cylinder has no polygons for the walkable search — so every step-up onto a wide cylinder failed into StepUpSlide and the player orbited the rim (probe-confirmed: [cyl-test] result=Slid with horizontal rim normals, launch-137-repro.log). Port the full family verbatim: dispatcher intersects_sphere 0x0053b440 (placement/ethereal detection, step-down cap landing, walkable probe, grounded step_sphere_up 0x0053b310, PathClipped collide_with_point 0x0053acb0, airborne land_on_cylinder 0x0053b3d0, Collide-flag exact-TOI cap rest) + collides_with_sphere 0x0053a880 + normal_of_collision 0x0053ab50 + slide_sphere 0x0053b2a0. Pseudocode + settled BN x87 ambiguities (via ACE cross-ref) + two ACE bugs found and NOT copied (head-slide foot-disp; see doc §8): docs/research/2026-07-05-ccylsphere-collision-family-pseudocode.md Ethereal cylinders now flow through retail's Layer-2 override (pc:276961) instead of the early-OK consume — same net #150 behavior, plus retail placement-blocked-by-cylinder semantics. SlideSphere gains a sphereNum param (retail slides the head sphere by its own displacement, 0x0053b843). Register: AP-6 retired; AP-83 added (PerfectClip TOI tail decoded per ACE, dead code until missiles). Tests: CylSphereFamilyTests (grounded step-up onto the exact platform shape, tall-cylinder block, airborne top landing, ethereal guard); the #42 self-shadow control assertion updated to the retail observable (denied movement — the old ~1m radial self-push was the approximation's artifact, not retail). Suites: Core 2533 / App 713 / UI 425 / Net 385 green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
532 lines
21 KiB
C#
532 lines
21 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.
|
|
// #145 D: register terrain for 0xA9B4 at (0,0,0) so TryGetTerrainOrigin
|
|
// succeeds for the outdoor seed cell (0xA9B40039). In production the
|
|
// streaming-center landblock is always resident before outdoor resolves run;
|
|
// we replicate that invariant here by registering a flat dummy terrain.
|
|
var freshCache = new PhysicsDataCache();
|
|
freshCache.CellGraph.RegisterTerrain(0xA9B4FFFFu, new TerrainSurface(FlatHeightmap(50), LinearHeightTable()), Vector3.Zero);
|
|
engine.DataCache = freshCache;
|
|
|
|
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 be
|
|
// INTERFERED WITH by the self-entry. This proves the registry
|
|
// actually causes a collision, so the following filtered case is not
|
|
// a vacuous pass.
|
|
//
|
|
// Observable updated for the 2026-07-05 CCylSphere family port: the
|
|
// old hand-rolled response radial-pushed the sphere ~1 m sideways
|
|
// (the original #42 symptom this test asserted). Retail's dispatcher
|
|
// (0x0053b440) resolves this geometry — airborne, dead-center on the
|
|
// cylinder axis, moving up — through land_on_cylinder → the Collide
|
|
// re-test, whose interp gate hard-stops (COLLIDED); ValidateTransition
|
|
// then reverts to a stay-put (no sideways teleport, Ok=true). The
|
|
// response-model-independent interference signal is the DENIED +Z
|
|
// movement: the sweep must NOT reach the +0.022 target.
|
|
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);
|
|
|
|
Assert.True(unfiltered.Position.Z < targetPos.Z - 0.01f,
|
|
$"Without movingEntityId, the sweep must collide with the mover's own " +
|
|
$"ShadowEntry and deny the +Z movement (retail: land_on_cylinder → " +
|
|
$"Collide re-test → COLLIDED → stay-put). Got Z={unfiltered.Position.Z:F4}, " +
|
|
$"target Z={targetPos.Z:F4}");
|
|
|
|
// 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);
|
|
}
|
|
}
|