using System; using System.Numerics; using AcDream.Core.Physics; using Xunit; namespace AcDream.Core.Tests.Physics; /// /// Landing-bounce family (#265, 2026-07-30, /// docs/research/2026-07-30-landing-bounce-family.md): the retail /// check_contact transition seed (CPhysicsObj::get_object_info 0x00511cc0 → /// check_contact 0x0050f5b0) plus the byte-decoded handle_all_collisions /// gate flags. Complements HandleAllCollisionsTests (landing reflect, /// walking no-reflect, Inelastic, fsf ladder — already pinned there). /// public class LandingBounceSeedingTests { private const uint Lb = 0x00010000u; private const uint Cell = 0x0001u; private static PhysicsEngine BuildFlatEngine() { var engine = new PhysicsEngine { DataCache = new PhysicsDataCache() }; engine.AddLandblock(Lb, new TerrainSurface(new byte[81], new float[256]), Array.Empty(), Array.Empty(), 0f, 0f); return engine; } private static PhysicsBody GroundedBody(Vector3 pos, Vector3 velocity) => new() { Position = pos, Orientation = Quaternion.Identity, State = PhysicsStateFlags.Gravity, TransientState = TransientStateFlags.Contact | TransientStateFlags.OnWalkable, Velocity = velocity, ContactPlaneValid = true, ContactPlane = new Plane(Vector3.UnitZ, 0f), GroundNormal = Vector3.UnitZ, }; private static ResolveResult ZeroMoveResolve(PhysicsEngine engine, PhysicsBody body) => engine.ResolveWithTransition( body.Position, body.Position, Cell, sphereRadius: 0.48f, sphereHeight: 1.835f, stepUpHeight: 0.55f, stepDownHeight: 0.55f, isOnGround: body.OnWalkable, body: body); [Fact] public void CheckContact_AtRestGroundedBody_KeepsContactOnZeroMoveResolve() { // v·n = 0 ≤ ε → check_contact holds → the seed carries the contact // plane through a zero-move resolve (retail standing still). var engine = BuildFlatEngine(); var body = GroundedBody(new Vector3(96f, 96f, 0.48f), Vector3.Zero); var result = ZeroMoveResolve(engine, body); Assert.True(result.IsOnGround); Assert.True(result.InContact); } [Fact] public void CheckContact_AscendingJumper_SeedsNoContact() { // Jump launch: v·n = +5.4 > ε (0.0002) → retail check_contact fails → // the transition runs contact-free (no glue, ballistic ascent). The // zero-move probe therefore reports airborne even though the body's // transient flags still say grounded from the previous tick. var engine = BuildFlatEngine(); var body = GroundedBody(new Vector3(96f, 96f, 0.48f), new Vector3(0f, 0f, 5.4f)); var result = ZeroMoveResolve(engine, body); Assert.False(result.IsOnGround); Assert.False(result.InContact); } [Fact] public void CheckContact_ContactWithoutStoredPlane_SeedsNothing() { // A Contact body with NO stored plane is unrepresentable in retail // (init_contact_plane always accompanies the CONTACT seed) — the // strict seed refuses it rather than echoing the caller's flags. var engine = BuildFlatEngine(); var body = GroundedBody(new Vector3(96f, 96f, 0.48f), Vector3.Zero); body.ContactPlaneValid = false; var result = ZeroMoveResolve(engine, body); Assert.False(result.IsOnGround); } [Fact] public void Reflect_DownhillSlopeLanding_FivePercentNormalReversal_TangentialKept() { // Slope normal 30° from vertical; impact velocity carries components // both along and into the slope. Retail 0x0051490c-0x00514959: // v' = v - (v·n)(elasticity+1)·n with DEFAULT_ELASTICITY 0.05 // (byte constant @0x007c6a7c) — the normal component REVERSES at 5% // (the bounce) and the tangential component is untouched (the carry). var n = Vector3.Normalize(new Vector3(0f, 0.5f, 0.8660254f)); var v = new Vector3(0f, 4f, -6f); var body = new PhysicsBody { Velocity = v, Elasticity = 0.05f, FramesStationaryFall = 0, }; PhysicsObjUpdate.HandleAllCollisions( body, collisionNormalValid: true, collisionNormal: n, prevContact: false, prevOnWalkable: false, nowOnWalkable: true); float dotBefore = Vector3.Dot(v, n); float dotAfter = Vector3.Dot(body.Velocity, n); Assert.True(dotBefore < 0f); // Normal component reversed and scaled by elasticity. Assert.True(MathF.Abs(dotAfter - (-dotBefore * 0.05f)) < 1e-5f, $"normal component: before={dotBefore}, after={dotAfter}"); // Tangential component preserved bit-for-bit (the reflect only adds // along n). Vector3 tangBefore = v - n * dotBefore; Vector3 tangAfter = body.Velocity - n * dotAfter; Assert.True((tangAfter - tangBefore).Length() < 1e-5f); } [Fact] public void Reflect_SleddingOverridesGroundedSuppression() { // Byte decode 0x0051479c: `test dword [esi+0xa8], 0x800000` — // PhysicsState.Sledding forces the reflect even while grounded-to- // grounded (the downhill sled keeps bouncing). var n = Vector3.UnitZ; var body = new PhysicsBody { Velocity = new Vector3(3f, 0f, -2f), Elasticity = 0.05f, State = PhysicsStateFlags.Sledding, FramesStationaryFall = 0, }; PhysicsObjUpdate.HandleAllCollisions( body, collisionNormalValid: true, collisionNormal: n, prevContact: true, prevOnWalkable: true, nowOnWalkable: true); Assert.True(MathF.Abs(body.Velocity.Z - 0.1f) < 1e-5f, $"expected reflected +0.1 (=2·0.05), got {body.Velocity.Z}"); Assert.Equal(3f, body.Velocity.X, precision: 5); } }