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>
This commit is contained in:
parent
67c3357246
commit
6ab269894a
6 changed files with 1027 additions and 151 deletions
287
tests/AcDream.Core.Tests/Physics/CylSphereFamilyTests.cs
Normal file
287
tests/AcDream.Core.Tests/Physics/CylSphereFamilyTests.cs
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -487,9 +487,20 @@ public class PhysicsEngineTests
|
|||
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.
|
||||
// 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,
|
||||
|
|
@ -498,11 +509,11 @@ public class PhysicsEngineTests
|
|||
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)");
|
||||
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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue