acdream/tests/AcDream.Core.Tests/Physics/SphereCollisionFamilyTests.cs
Erik 96ae274081 fix #182: port CSphere collision family — retail-faithful crowd wiggle (retires TS-45)
Humanoid creatures/players collide as body Spheres (ShadowShapeBuilder emits
Sphere-type shadows for a Setup with Spheres + no CylSpheres), so player-vs-monster
crowd contact ran through Transition.SphereCollision — a hand-rolled 3-D wall-slide
(register TS-45), NOT a port of retail CSphere::intersects_sphere. It shaved no eps,
force-pushed each contact RADIALLY to a fixed combinedR+1cm shell, ignored the head
sphere, and always returned Slid. In a crowd the opposing radial de-penetration
pushes from neighbours fight each other -> the player wedges and can't wiggle free
(the user's live report).

Port the full CSphere family verbatim — dispatcher 0x00537A80 + step_sphere_up /
slide_sphere / land_on_sphere / collide_with_point / step_sphere_down — the direct
analog of the 2026-07-05 CCylSphere port (#172). The grounded slide now routes
through the shared crease SlideSphere (0x00537440, #116-Ghidra-confirmed) ->
tangential shuffle along the contact toward gaps, retail-faithful. isCreature
(target creature/missile) gates OFF the stand-on/land-on branches (2 & 5). ACE
Sphere.cs = readable oracle; pseudocode doc 2026-07-07-csphere-collision-family.

Retail-faithfulness verified: CTransition::validate_transition (0x0050aa70:272593)
reverts curr_pos on any non-clean-OK step, so a deep-mutual-overlap start wedges in
retail too — the realistic crowd-edge graze slides free (SphereCollisionFamilyTests
slide-around trajectory: player grazes a creature's SW, curves around its west side,
continues N).

TS-45 retired, AP-84 added (PerfectClip TOI dead in M1.5). Core 2603/0, App 741/0.
Pending user visual gate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 11:25:56 +02:00

251 lines
11 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>CSphere::intersects_sphere</c> collision
/// family port (2026-07-07) — dispatcher <c>0x00537A80</c> + <c>step_sphere_up</c>
/// <c>0x00537900</c> + <c>slide_sphere</c> <c>0x00537440</c> +
/// <c>land_on_sphere</c> <c>0x005379a0</c> + <c>collide_with_point</c>
/// <c>0x00537230</c> + <c>step_sphere_down</c> <c>0x00536d20</c>. Pseudocode:
/// docs/research/2026-07-07-csphere-collision-family-pseudocode.md.
///
/// <para>
/// The driving repro: the user's live report that a player packed inside a crowd
/// of monsters gets wedged and can't wiggle free, where retail leaves room to
/// shuffle out. Humanoid creatures/players collide as body <b>Spheres</b>
/// (Setup.Spheres → <see cref="ShadowShapeBuilder.FromSetup"/> emits
/// <see cref="ShadowCollisionType.Sphere"/>), so the crowd contact runs through
/// <c>Transition.SphereCollision</c>. Before this port that method was a
/// hand-rolled 3-D wall-slide with a forced fixed de-penetration (register
/// <b>TS-45</b>) — opposing radial pushes from neighbours cancelled and the
/// player wedged. The faithful family routes the slide through the shared crease
/// projection, restoring retail's tangential shuffle. Synthetic spheres only — no
/// dat dependency.
/// </para>
/// </summary>
public class SphereCollisionFamilyTests
{
private readonly ITestOutputHelper _out;
public SphereCollisionFamilyTests(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;
private const float CreatureRadius = 0.48f; // a humanoid body sphere
/// <summary>
/// THE headline behavior: the crease slide lets a grounded player SHUFFLE
/// AROUND a body-sphere creature that partly blocks the path, instead of
/// sticking to it. The creature sits slightly EAST of the player's northward
/// line; the player walks N, grazes the creature's south-west, and must slide
/// along it (crease = collisionNormal × ground-up) and continue past.
///
/// <para>
/// This is the retail-faithful tangential slide the port restores. The
/// pre-2026-07-07 hand-rolled SphereCollision force-pushed the contact
/// RADIALLY to a fixed <c>combinedR + 1 cm</c> shell (shoving the player
/// south-west, back along its approach) — TS-45. In a crowd those radial
/// shoves from several neighbours fight each other and wedge the player; the
/// tangential crease slide is what leaves "room to shuffle out". The player
/// starts CLEAR (not penetrating) — a start deep inside overlapping spheres
/// reverts in retail too (validate_transition restores curr_pos on a
/// non-clean step, 0x0050aa70:272593), so that is not a valid repro.
/// </para>
/// </summary>
[Fact]
public void GroundedDiagonalApproach_SlidesPastOffsetCreature()
{
var engine = BuildEngine(out _);
RegisterCreatureSphere(engine, 0xC0C0u, 10.35f, 12f); // slightly EAST of the N path
var body = MakeGroundedBody(new Vector3(10f, 10f, 0f));
Vector3 pos = body.Position;
uint cellId = TestCellId;
bool grounded = true;
var perTick = new Vector3(0f, 0.08f, 0f); // push NORTH
for (int tick = 0; tick < 60; 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($"tick {tick,2}: ({pos.X:F3},{pos.Y:F3})");
}
_out.WriteLine($"final pos=({pos.X:F3},{pos.Y:F3},{pos.Z:F3}) grounded={grounded}");
// The creature centre is at Y=12. If the player slid around it, it ends
// NORTH of the creature; if it stuck, it wedges near Y≈11 (surface at
// Y = 12 sqrt(0.96² 0.35²) ≈ 11.11).
Assert.True(pos.Y > 12.5f,
$"Player must slide around the offset creature and continue north, not stick at " +
$"its surface; got Y={pos.Y:F3}");
}
/// <summary>
/// A single body-sphere creature dead ahead: the player pushing straight into
/// it must stop AT the surface (a head-on push has no crease tangent → the
/// slide degenerates → Collided) and must NOT penetrate or pass through.
/// Surface contact is at Y = 11.5 (0.48+0.48) = 10.54.
/// </summary>
[Fact]
public void GroundedSingleCreature_HeadOnPush_BlocksWithoutPenetration()
{
var engine = BuildEngine(out _);
RegisterCreatureSphere(engine, 0xC0B0u, 12f, 11.5f); // due north
var body = MakeGroundedBody(new Vector3(12f, 10f, 0f));
Vector3 pos = body.Position;
uint cellId = TestCellId;
bool grounded = true;
var perTick = new Vector3(0f, 0.08f, 0f); // straight in
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 < 10.7f,
$"Player must be blocked at the sphere surface (Y≈10.54), not penetrate; got Y={pos.Y:F3}");
Assert.True(pos.Y > 10.3f,
$"Player must actually reach the creature (not stop early); got Y={pos.Y:F3}");
}
/// <summary>
/// An ETHEREAL sphere (state 0x4, non-static) is fully passable — the branch-1
/// early-OK consume plus the caller's Layer-2 override. Guards the door/ghost
/// passability against the family rewrite.
/// </summary>
[Fact]
public void GroundedEtherealSphere_IsFullyPassable()
{
var engine = BuildEngine(out _);
RegisterCreatureSphere(engine, 0xC0E0u, 12f, 11.5f,
state: 0x4u, isCreatureFlag: false); // ETHEREAL_PS, non-static
var body = MakeGroundedBody(new Vector3(12f, 10f, 0f));
Vector3 pos = body.Position;
uint cellId = TestCellId;
bool grounded = true;
var perTick = new Vector3(0f, 0.08f, 0f);
for (int tick = 0; tick < 45; 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 > 12.5f,
$"Ethereal sphere must not block (walked from 10 past the axis); got Y={pos.Y:F3}");
}
// ───────────────────────────────────────────────────────────────
// Harness (mirrors CylSphereFamilyTests)
// ───────────────────────────────────────────────────────────────
private static PhysicsEngine BuildEngine(out PhysicsDataCache cache)
{
cache = new PhysicsDataCache();
var engine = new PhysicsEngine { DataCache = cache };
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;
}
/// <summary>
/// Register a humanoid body sphere at foot height (Z = SphereRadius) so the
/// player's foot sphere overlaps it horizontally. Flagged as a creature by
/// default (the crowd case), mimicking a live monster's body-sphere shadow.
/// </summary>
private static void RegisterCreatureSphere(PhysicsEngine engine, uint entityId,
float x, float y, float radius = CreatureRadius, uint state = 0u,
bool isCreatureFlag = true)
{
engine.ShadowObjects.Register(
entityId, gfxObjId: 0u,
new Vector3(x, y, SphereRadius), Quaternion.Identity, radius,
worldOffsetX: 0f, worldOffsetY: 0f, landblockId: TestLandblockId,
collisionType: ShadowCollisionType.Sphere,
cylHeight: 0f, scale: 1f,
state: state,
flags: isCreatureFlag ? EntityCollisionFlags.IsCreature : EntityCollisionFlags.None,
isStatic: false);
}
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,
};
}
}