using System; using System.Numerics; using AcDream.Core.Physics; using Xunit; using Xunit.Abstractions; using Plane = System.Numerics.Plane; namespace AcDream.Core.Tests.Physics; /// /// Conformance tests for the retail CSphere::intersects_sphere collision /// family port (2026-07-07) — dispatcher 0x00537A80 + step_sphere_up /// 0x00537900 + slide_sphere 0x00537440 + /// land_on_sphere 0x005379a0 + collide_with_point /// 0x00537230 + step_sphere_down 0x00536d20. Pseudocode: /// docs/research/2026-07-07-csphere-collision-family-pseudocode.md. /// /// /// 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 Spheres /// (Setup.Spheres → emits /// ), so the crowd contact runs through /// Transition.SphereCollision. Before this port that method was a /// hand-rolled 3-D wall-slide with a forced fixed de-penetration (register /// TS-45) — 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. /// /// 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 /// /// 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. /// /// /// 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 combinedR + 1 cm 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. /// /// [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}"); } /// /// 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. /// [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}"); } /// /// 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. /// [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(), portals: Array.Empty(), worldOffsetX: 0f, worldOffsetY: 0f); return engine; } /// /// 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. /// 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, }; } }