acdream/tests/AcDream.Core.Tests/Physics/CylSphereFamilyTests.cs
Erik 6ab269894a fix #172: port the retail CCylSphere collision family (platform step-up)
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>
2026-07-05 14:52:28 +02:00

287 lines
12 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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,
};
}
}