using System; using System.Collections.Generic; using System.Numerics; using DatReaderWriter.Enums; using DatReaderWriter.Types; using AcDream.Core.Physics; using Xunit; using Plane = System.Numerics.Plane; namespace AcDream.Core.Tests.Physics; /// /// #137 mechanism 2 — the sliding-normal absorbing wedge (2026-07-06). /// /// /// Retail's in-transition collision_info.sliding_normal has exactly ONE /// writer besides the per-frame seed: CTransition::validate_transition /// (0x0050ac21-ac30, "if collision_normal_valid → set_sliding_normal"). The /// BSP collision layer NEVER writes it — BSPTREE::find_collisions' /// Contact branch dispatches full hits to step_sphere_up (foot, /// 0x0053a719) / BSPTREE::slide_sphere (head, 0x0053a697), and /// CSphere::slide_sphere (0x00537440) slides IN-FRAME via /// add_offset_to_check_pos without touching sliding_normal /// (grep-verified: zero sliding_normal references between 0x005155 and /// 0x00841f in acclient_2013_pseudo_c.txt). ACE mirrors this: the only /// SetSlidingNormal call sites are CollisionInfo.cs:58 (the setter) and /// Transition.cs:1027 (validate). The body-side persistence /// (CPhysicsObj::SetPositionInternal 0x005154c2, SLIDING_TS bit-4 sync /// at 0x005154e1) runs only on transition SUCCESS. /// /// /// /// acdream's BSPQuery Contact branch carried stub fallbacks /// (SetCollisionNormal + SetSlidingNormal + return Slid) instead of the real /// slide. The leaked sliding normal survived to the transition end, the /// unconditional body writeback persisted it, and the next frame's seed /// projected an exactly-anti-parallel push to zero — aborting at step 0 /// BEFORE any collision test could refresh the state. Live shape: the /// Facility Hub corridor phantom (launch-175-verify2.log:42858 — one wall /// hit at the 0x8A02016E→0x8A02017A seam, then endless ok=False hit=no /// zero-advance resolves; strafe escapes). /// /// public class Issue137SlidingNormalLifecycleTests { // ========================================================================= // Site-level pins — BSPQuery.FindCollisions Contact branch must not write // the transition's sliding normal (retail: only validate_transition does). // ========================================================================= /// /// Contact foot-sphere FULL HIT with the step-up recursion unavailable /// (engine=null / step-up already in progress) must dispatch the real /// sphere slide — never the SetSlidingNormal stub. /// /// /// Retail: a blocked step-up funnels to SPHEREPATH::step_up_slide → /// CSphere::slide_sphere (ACE SpherePath.cs:316 → Sphere.cs:558) — /// in-frame slide, no sliding_normal write. Face-on into a vertical wall /// while grounded: the crease projection (cross(wallN, floorN)) has no /// component along the movement, the slide offset is degenerate /// (< F_EPSILON), and slide_sphere returns COLLIDED_TS (0x00537735). /// /// [Fact] public void ContactFootFullHit_StepUpUnavailable_RealSlide_NoSlidingNormalWrite() { var (root, resolved) = BSPStepUpFixtures.TallWall(); // Grounded mover pushing face-on (+X) into the 5 m wall at x=0.5 // (normal −X). Sphere center reach 0.35+0.2=0.55 penetrates the wall. var from = new Vector3(0.10f, 0f, BSPStepUpFixtures.SphereRadius); var to = new Vector3(0.35f, 0f, BSPStepUpFixtures.SphereRadius); var t = BSPStepUpFixtures.MakeGroundedTransition(from, to); var localSphere = new DatReaderWriter.Types.Sphere { Origin = to, Radius = BSPStepUpFixtures.SphereRadius, }; var result = BSPQuery.FindCollisions( root, resolved, t, localSphere, null, from, Vector3.UnitZ, 1.0f); Assert.False(t.CollisionInfo.SlidingNormalValid, "find_collisions must not write collision_info.sliding_normal — " + "retail's only in-transition writer is validate_transition " + "(0x0050ac21). A sliding normal leaked here survives to the body " + "writeback and absorbs the next frame's forward offset (#137)."); Assert.True(t.CollisionInfo.CollisionNormalValid, "The real slide records the collision normal (CSphere::slide_sphere " + "→ set_collision_normal)."); Assert.Equal(TransitionState.Collided, result); } /// /// Contact HEAD-sphere FULL HIT must dispatch BSPTREE::slide_sphere /// (retail 0x0053a697; ACE BSPTree.cs:202 → 310-316: the real /// Sphere.SlideSphere on GlobalSphere[0]) — never the stub. /// The corridor phantom's portal-side polys span head height; this is the /// path that recorded the (−1,0,0) normal the wedge absorbed on. /// [Fact] public void ContactHeadFullHit_RealSlide_NoSlidingNormalWrite() { // Raised wall: z ∈ [0.6, 5] at x=0.5, normal −X. The foot sphere // (center z=0.2, r=0.2 → z-span [0, 0.4]) passes under it; the head // sphere (center z=0.8 → z-span [0.6, 1.0]) fully hits it. var resolved = new Dictionary(); var floorVerts = new[] { new Vector3(-2f, -1f, 0f), new Vector3(2f, -1f, 0f), new Vector3(2f, 1f, 0f), new Vector3(-2f, 1f, 0f), }; resolved[1] = new ResolvedPolygon { Vertices = floorVerts, Plane = new Plane(Vector3.UnitZ, 0f), NumPoints = 4, SidesType = CullMode.None, }; var wallNormal = new Vector3(-1f, 0f, 0f); var wallVerts = new[] { new Vector3(0.5f, -1f, 0.6f), new Vector3(0.5f, -1f, 5f), new Vector3(0.5f, 1f, 5f), new Vector3(0.5f, 1f, 0.6f), }; resolved[2] = new ResolvedPolygon { Vertices = wallVerts, Plane = new Plane(wallNormal, 0.5f), // n·p + d = 0 at x=0.5 NumPoints = 4, SidesType = CullMode.None, }; var leaf = new PhysicsBSPNode { Type = BSPNodeType.Leaf, BoundingSphere = new DatReaderWriter.Types.Sphere { Origin = new Vector3(0f, 0f, 2.5f), Radius = 10f }, }; leaf.Polygons.Add(1); leaf.Polygons.Add(2); var from = new Vector3(0.10f, 0f, BSPStepUpFixtures.SphereRadius); var to = new Vector3(0.35f, 0f, BSPStepUpFixtures.SphereRadius); var t = BSPStepUpFixtures.MakeGroundedTransition(from, to); var footSphere = new DatReaderWriter.Types.Sphere { Origin = to, Radius = BSPStepUpFixtures.SphereRadius, }; var headSphere = new DatReaderWriter.Types.Sphere { Origin = new Vector3(to.X, to.Y, 0.8f), Radius = BSPStepUpFixtures.SphereRadius, }; var result = BSPQuery.FindCollisions( leaf, resolved, t, footSphere, headSphere, from, Vector3.UnitZ, 1.0f); Assert.False(t.CollisionInfo.SlidingNormalValid, "Head full hit must go through the real BSPTREE::slide_sphere — " + "no sliding_normal write at the BSP layer (retail 0x0053a697)."); Assert.True(t.CollisionInfo.CollisionNormalValid); Assert.Equal(TransitionState.Collided, result); } // ========================================================================= // Engine-level lifecycle pin — the retail persist/absorb/clear cycle at a // REAL wall. Guards the fix against regressing wall behavior, and // documents where retail CLEARS the body's sliding state (the successful // transition's writeback, when no step re-records a collision). // ========================================================================= private const uint CellId = 0xA9B40157u; private static PhysicsEngine BuildWallEngine() { var (wallRoot, wallResolved) = BSPStepUpFixtures.TallWall(); var cell = new CellPhysics { BSP = new PhysicsBSPTree { Root = wallRoot }, WorldTransform = Matrix4x4.Identity, InverseWorldTransform = Matrix4x4.Identity, Resolved = wallResolved, CellBSP = new CellBSPTree { Root = new CellBSPNode { Type = BSPNodeType.Leaf }, }, }; var engine = new PhysicsEngine(); engine.DataCache = new PhysicsDataCache(); // Flat terrain strip so the outdoor fall-through has something to // sample if it ever fires (same shape as FindEnvCollisionsMultiCellTests). var heights = new byte[81]; Array.Fill(heights, (byte)0); engine.AddLandblock(0xA9B4FFFFu, new TerrainSurface(heights, BuildHeightTable()), Array.Empty(), Array.Empty(), worldOffsetX: 0f, worldOffsetY: 0f); engine.DataCache.RegisterCellStructForTest(CellId, cell); return engine; } private static float[] BuildHeightTable() { var ht = new float[256]; for (int i = 0; i < 256; i++) ht[i] = i * 1.0f; return ht; } private static PhysicsBody GroundedBody() { var body = new PhysicsBody(); body.ContactPlaneValid = true; body.ContactPlane = new Plane(Vector3.UnitZ, 0f); body.TransientState |= TransientStateFlags.Contact | TransientStateFlags.OnWalkable; return body; } private ResolveResult ResolveForward(PhysicsEngine engine, PhysicsBody body, Vector3 from, Vector3 to) => engine.ResolveWithTransition( currentPos: from, targetPos: to, cellId: CellId, sphereRadius: BSPStepUpFixtures.SphereRadius, sphereHeight: 0f, // single sphere — keeps the scenario deterministic stepUpHeight: 0.04f, // cannot scale the 5 m wall stepDownHeight: 0.04f, isOnGround: true, body: body); /// /// The full retail lifecycle at a real wall: /// (1) a blocked face-on push persists the validate-recorded sliding /// normal via the SUCCESS writeback (SetPositionInternal bit-4 sync, /// 0x005154e1); /// (2) the next exactly-anti-parallel push is absorbed by the seed /// (get_object_info 0x00511d44 → adjust_offset projects to zero → /// find_transitional_position's step-0 small-offset abort) — the /// retail cache semantics: "still pressed against this wall"; /// (3) an oblique push escapes along the wall tangent, the step runs /// without re-recording a collision, and the successful writeback /// CLEARS the body's sliding state (sliding_normal_valid==0 → bit /// 4 cleared). /// [Fact] public void WallLifecycle_PersistOnBlock_AbsorbExactAntiParallel_ClearOnEscape() { var engine = BuildWallEngine(); var body = GroundedBody(); // ── 1. Face-on +X into the wall at x=0.5 (normal −X) ───────────── var r1 = ResolveForward(engine, body, from: new Vector3(0.10f, 0f, 0f), to: new Vector3(0.35f, 0f, 0f)); Assert.True(body.TransientState.HasFlag(TransientStateFlags.Sliding), "A blocked push must persist the validate-recorded sliding normal " + "(retail SetPositionInternal 0x005154c2 on transition success)."); Assert.True(body.SlidingNormal.X < -0.9f, $"Persisted normal should face the mover (−X); got {body.SlidingNormal}."); Assert.True(r1.Position.X + BSPStepUpFixtures.SphereRadius <= 0.5f + PhysicsGlobals.EPSILON * 20f, $"The 5 m wall must block the sphere; reach={r1.Position.X + BSPStepUpFixtures.SphereRadius:F4}."); // ── 2. Exactly-anti-parallel push again: absorbed frame ────────── var r2 = ResolveForward(engine, body, from: r1.Position, to: r1.Position + new Vector3(0.15f, 0f, 0f)); Assert.False(r2.Ok, "The seeded sliding normal projects the exactly-anti-parallel " + "offset to zero → step-0 abort (retail find_transitional_position " + "0050bfb7/0050c0ef). Faithful absorbed frame at a REAL wall."); Assert.True(body.TransientState.HasFlag(TransientStateFlags.Sliding), "A failed transition leaves the body's sliding state untouched " + "(retail: SetPositionInternal never runs on failure)."); // ── 3. Oblique push escapes and CLEARS the persisted state ─────── var r3 = ResolveForward(engine, body, from: r2.Position, to: r2.Position + new Vector3(0.10f, 0.15f, 0f)); Assert.True(r3.Ok, "Oblique push must escape along the wall tangent."); Assert.True(r3.Position.Y > r2.Position.Y + 0.05f, $"Expected tangential advance along +Y; got Y={r3.Position.Y:F4} " + $"(from {r2.Position.Y:F4})."); Assert.False(body.TransientState.HasFlag(TransientStateFlags.Sliding), "A successful transition whose steps re-record no collision must " + "CLEAR the body's sliding state (retail SetPositionInternal " + "0x005154e1: bit 4 synced from the transition's final " + "sliding_normal_valid, which each step clears before its insert)."); Assert.Equal(Vector3.Zero, body.SlidingNormal); } }