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);
}
}