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>
287 lines
12 KiB
C#
287 lines
12 KiB
C#
using System;
|
||
using System.Numerics;
|
||
using AcDream.Core.Physics;
|
||
using Xunit;
|
||
using Xunit.Abstractions;
|
||
using Plane = System.Numerics.Plane;
|
||
|
||
namespace AcDream.Core.Tests.Physics;
|
||
|
||
/// <summary>
|
||
/// Conformance tests for the retail <c>CCylSphere</c> collision family port
|
||
/// (2026-07-05) — dispatcher <c>0x0053b440</c> + <c>step_sphere_down</c>
|
||
/// <c>0x0053a9b0</c> + <c>step_sphere_up</c> <c>0x0053b310</c> +
|
||
/// <c>land_on_cylinder</c> <c>0x0053b3d0</c>. Pseudocode:
|
||
/// docs/research/2026-07-05-ccylsphere-collision-family-pseudocode.md.
|
||
///
|
||
/// <para>
|
||
/// The driving repro: the Holtburg town-network portal platform (stab
|
||
/// 0xC0A9B465, Setup 0x020019E3) registers a WIDE LOW cylinder
|
||
/// (r=2.597 m, h=0.256 m). Retail steps a grounded player UP ONTO its flat
|
||
/// top; the pre-port approximation could only radial-slide, so the player
|
||
/// orbited the rim forever (launch-137-repro.log, 2026-07-05). These tests
|
||
/// pin the three retail behaviors the family provides: grounded
|
||
/// step-up-onto-top, too-tall side slide, and the airborne top landing.
|
||
/// Synthetic cylinders only — no dat dependency.
|
||
/// </para>
|
||
/// </summary>
|
||
public class CylSphereFamilyTests
|
||
{
|
||
private readonly ITestOutputHelper _out;
|
||
public CylSphereFamilyTests(ITestOutputHelper output) => _out = output;
|
||
|
||
private const uint TestLandblockId = 0xA9B40000u;
|
||
private const uint TestCellId = TestLandblockId | 0x0001u; // landcell (0,0)
|
||
|
||
private const float SphereRadius = 0.48f; // retail player capsule radius
|
||
private const float SphereHeight = 1.20f;
|
||
private const float StepUpHeight = 0.60f;
|
||
private const float StepDownHeight = 0.04f;
|
||
|
||
// The live platform's registered shape ([cyl-test] launch-137-repro.log).
|
||
private const float PlatformRadius = 2.597f;
|
||
private const float PlatformHeight = 0.256f;
|
||
|
||
/// <summary>
|
||
/// The portal-platform repro: a grounded player walking into the wide low
|
||
/// cylinder must STEP UP onto its flat top (retail
|
||
/// grounded branch → step_sphere_up → CTransition::step_up, whose
|
||
/// step-down probe lands via step_sphere_down's top-disc contact plane) —
|
||
/// not slide around the rim.
|
||
/// </summary>
|
||
[Fact]
|
||
public void Grounded_WalkIntoWideLowCylinder_StepsUpOntoTop()
|
||
{
|
||
var engine = BuildEngine(out _);
|
||
RegisterCylinder(engine, entityId: 0xCAFEu,
|
||
worldPos: new Vector3(12f, 14f, 0f),
|
||
radius: PlatformRadius, height: PlatformHeight);
|
||
|
||
var body = MakeGroundedBody(new Vector3(12f, 10.4f, 0f));
|
||
Vector3 pos = body.Position;
|
||
uint cellId = TestCellId;
|
||
bool grounded = true;
|
||
var perTick = new Vector3(0f, 0.10f, 0f);
|
||
|
||
for (int tick = 0; tick < 40; tick++)
|
||
{
|
||
var result = engine.ResolveWithTransition(
|
||
pos, pos + perTick, cellId,
|
||
SphereRadius, SphereHeight, StepUpHeight, StepDownHeight,
|
||
grounded,
|
||
body: body,
|
||
moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
|
||
movingEntityId: 0);
|
||
|
||
body.Position = result.Position;
|
||
pos = result.Position;
|
||
cellId = result.CellId;
|
||
grounded = result.IsOnGround;
|
||
}
|
||
|
||
_out.WriteLine($"final pos=({pos.X:F3},{pos.Y:F3},{pos.Z:F3}) grounded={grounded}");
|
||
|
||
// Rim contact is at Y ≈ 14 − 2.597 − 0.48 = 10.92. Pre-port the player
|
||
// pinned there (Z stayed 0, Y never passed the rim). Post-port the
|
||
// player must be standing ON the platform top.
|
||
Assert.True(pos.Y > 11.5f,
|
||
$"Player must advance past the rim contact (pre-port it pinned at Y≈10.9); got Y={pos.Y:F3}");
|
||
Assert.True(MathF.Abs(pos.Z - PlatformHeight) < 0.05f,
|
||
$"Player must stand ON the platform top (Z≈{PlatformHeight:F3}); got Z={pos.Z:F3}");
|
||
Assert.True(grounded, "Player must remain grounded after stepping onto the platform");
|
||
}
|
||
|
||
/// <summary>
|
||
/// A tall thin cylinder (the Holtburg torch shape, r=0.2 h=2.2 — #149)
|
||
/// exceeds step_up_height: the grounded dead-center approach must NOT
|
||
/// step up and must NOT pass through — retail slides (dead-center the
|
||
/// crease projection degenerates to a hard stop).
|
||
/// </summary>
|
||
[Fact]
|
||
public void Grounded_WalkIntoTallCylinder_BlocksBeforeAxis()
|
||
{
|
||
var engine = BuildEngine(out _);
|
||
RegisterCylinder(engine, entityId: 0xF00Du,
|
||
worldPos: new Vector3(12f, 14f, 0f),
|
||
radius: 0.2f, height: 2.2f);
|
||
|
||
var body = MakeGroundedBody(new Vector3(12f, 12.6f, 0f));
|
||
Vector3 pos = body.Position;
|
||
uint cellId = TestCellId;
|
||
bool grounded = true;
|
||
var perTick = new Vector3(0f, 0.10f, 0f);
|
||
|
||
for (int tick = 0; tick < 30; tick++)
|
||
{
|
||
var result = engine.ResolveWithTransition(
|
||
pos, pos + perTick, cellId,
|
||
SphereRadius, SphereHeight, StepUpHeight, StepDownHeight,
|
||
grounded,
|
||
body: body,
|
||
moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
|
||
movingEntityId: 0);
|
||
|
||
body.Position = result.Position;
|
||
pos = result.Position;
|
||
cellId = result.CellId;
|
||
grounded = result.IsOnGround;
|
||
}
|
||
|
||
_out.WriteLine($"final pos=({pos.X:F3},{pos.Y:F3},{pos.Z:F3}) grounded={grounded}");
|
||
|
||
// Surface contact at Y = 14 − 0.2 − 0.48 = 13.32.
|
||
Assert.True(pos.Y < 13.4f,
|
||
$"Tall cylinder must block the dead-center approach; got Y={pos.Y:F3}");
|
||
Assert.True(pos.Z < 0.5f,
|
||
$"Player must NOT end up on top of a 2.2 m cylinder; got Z={pos.Z:F3}");
|
||
}
|
||
|
||
/// <summary>
|
||
/// Airborne landing: a falling sphere over the platform center must land
|
||
/// ON the flat top (land_on_cylinder → Collide re-test → branch-5
|
||
/// exact-TOI rest + top-disc contact plane), not fall through to the
|
||
/// terrain inside the footprint.
|
||
/// </summary>
|
||
[Fact]
|
||
public void Airborne_FallOntoWideCylinder_LandsOnTop()
|
||
{
|
||
var engine = BuildEngine(out _);
|
||
RegisterCylinder(engine, entityId: 0xCAFEu,
|
||
worldPos: new Vector3(12f, 14f, 0f),
|
||
radius: PlatformRadius, height: PlatformHeight);
|
||
|
||
Vector3 pos = new(12f, 14f, 1.0f); // 1 m above the base, over the center
|
||
uint cellId = TestCellId;
|
||
bool grounded = false;
|
||
var perTick = new Vector3(0f, 0f, -0.25f);
|
||
|
||
int landedTick = -1;
|
||
for (int tick = 0; tick < 20; tick++)
|
||
{
|
||
var result = engine.ResolveWithTransition(
|
||
pos, pos + perTick, cellId,
|
||
SphereRadius, SphereHeight, StepUpHeight, StepDownHeight,
|
||
grounded,
|
||
body: null,
|
||
moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
|
||
movingEntityId: 0);
|
||
|
||
pos = result.Position;
|
||
cellId = result.CellId;
|
||
grounded = result.IsOnGround;
|
||
|
||
if (grounded) { landedTick = tick; break; }
|
||
}
|
||
|
||
_out.WriteLine($"final pos=({pos.X:F3},{pos.Y:F3},{pos.Z:F3}) grounded={grounded} landedTick={landedTick}");
|
||
|
||
Assert.True(grounded, "Falling sphere must land (ground) on the platform top");
|
||
Assert.True(MathF.Abs(pos.Z - PlatformHeight) < 0.05f,
|
||
$"Landing must rest on the top disc (Z≈{PlatformHeight:F3}), not the terrain " +
|
||
$"(Z=0) inside the footprint; got Z={pos.Z:F3}");
|
||
}
|
||
|
||
/// <summary>
|
||
/// Ethereal cylinders stay fully passable through the caller's Layer-2
|
||
/// override (pc:276961-276989) — branch 1 detects, the override clears.
|
||
/// Guards the #150 door behavior against the branch-1 change from the
|
||
/// old early-OK consume.
|
||
/// </summary>
|
||
[Fact]
|
||
public void Grounded_EtherealCylinder_IsFullyPassable()
|
||
{
|
||
var engine = BuildEngine(out _);
|
||
RegisterCylinder(engine, entityId: 0xE7E7u,
|
||
worldPos: new Vector3(12f, 14f, 0f),
|
||
radius: 0.2f, height: 2.2f,
|
||
state: 0x4u); // ETHEREAL_PS, non-static
|
||
|
||
var body = MakeGroundedBody(new Vector3(12f, 12.6f, 0f));
|
||
Vector3 pos = body.Position;
|
||
uint cellId = TestCellId;
|
||
bool grounded = true;
|
||
var perTick = new Vector3(0f, 0.10f, 0f);
|
||
|
||
for (int tick = 0; tick < 30; tick++)
|
||
{
|
||
var result = engine.ResolveWithTransition(
|
||
pos, pos + perTick, cellId,
|
||
SphereRadius, SphereHeight, StepUpHeight, StepDownHeight,
|
||
grounded,
|
||
body: body,
|
||
moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
|
||
movingEntityId: 0);
|
||
|
||
body.Position = result.Position;
|
||
pos = result.Position;
|
||
cellId = result.CellId;
|
||
grounded = result.IsOnGround;
|
||
}
|
||
|
||
_out.WriteLine($"final pos=({pos.X:F3},{pos.Y:F3},{pos.Z:F3})");
|
||
|
||
Assert.True(pos.Y > 14.5f,
|
||
$"Ethereal cylinder must not block (walked from 12.6 to past the axis); got Y={pos.Y:F3}");
|
||
}
|
||
|
||
// ───────────────────────────────────────────────────────────────
|
||
// Harness
|
||
// ───────────────────────────────────────────────────────────────
|
||
|
||
private static PhysicsEngine BuildEngine(out PhysicsDataCache cache)
|
||
{
|
||
cache = new PhysicsDataCache();
|
||
var engine = new PhysicsEngine { DataCache = cache };
|
||
|
||
// Flat terrain at Z=0 across the whole landblock.
|
||
var heights = new byte[81];
|
||
var heightTable = new float[256]; // all zero → terrain Z = 0
|
||
engine.AddLandblock(
|
||
landblockId: TestLandblockId,
|
||
terrain: new TerrainSurface(heights, heightTable),
|
||
cells: Array.Empty<CellSurface>(),
|
||
portals: Array.Empty<PortalPlane>(),
|
||
worldOffsetX: 0f,
|
||
worldOffsetY: 0f);
|
||
|
||
return engine;
|
||
}
|
||
|
||
private static void RegisterCylinder(PhysicsEngine engine, uint entityId,
|
||
Vector3 worldPos, float radius, float height, uint state = 0u)
|
||
{
|
||
engine.ShadowObjects.Register(
|
||
entityId, gfxObjId: 0u,
|
||
worldPos, Quaternion.Identity, radius,
|
||
worldOffsetX: 0f, worldOffsetY: 0f, landblockId: TestLandblockId,
|
||
collisionType: ShadowCollisionType.Cylinder,
|
||
cylHeight: height,
|
||
state: state);
|
||
}
|
||
|
||
private static PhysicsBody MakeGroundedBody(Vector3 position)
|
||
{
|
||
var floorPlane = new Plane(Vector3.UnitZ, 0f);
|
||
var floorVerts = new[]
|
||
{
|
||
new Vector3(-100f, -100f, 0f),
|
||
new Vector3( 100f, -100f, 0f),
|
||
new Vector3( 100f, 100f, 0f),
|
||
new Vector3(-100f, 100f, 0f),
|
||
};
|
||
|
||
return new PhysicsBody
|
||
{
|
||
Position = position,
|
||
Orientation = Quaternion.Identity,
|
||
ContactPlaneValid = true,
|
||
ContactPlane = floorPlane,
|
||
ContactPlaneCellId = TestCellId,
|
||
WalkablePolygonValid = true,
|
||
WalkablePlane = floorPlane,
|
||
WalkableVertices = floorVerts,
|
||
WalkableUp = Vector3.UnitZ,
|
||
TransientState = TransientStateFlags.Contact | TransientStateFlags.OnWalkable,
|
||
};
|
||
}
|
||
}
|