Retail jump landings BOUNCE: the floor touch records both a contact plane (grounding) AND a collision normal (collided_with_environment), and handle_all_collisions reflects the unmodified impact velocity off it at 5% elasticity (v += -(v.n)(elasticity+1).n, DEFAULT_ELASTICITY 0.05 @0x007c6a7c). Our transition already recorded both facts; the bounce was suppressed by the AD-25 adaptation stack in the per-tick commit: a Velocity.Z<=0 landing gate (needed because the resolver glued ascending movers to the ground) plus a landing Velocity.Z=0 hand-zero whose stated purpose was making the reflect a no-op. Downhill glided instead of bouncing, flat-ground landings had no pop, and uphill jumps flapped between grounded/airborne against the animation machine. Three retail mechanisms replace the stack: - check_contact (0x0050f5b0) seeding in ResolveWithTransition: a body in CONTACT seeds the transition's contact only while v.contactPlane.N <= 0.0002; moving away seeds the last-known plane alone (get_object_info 0x00511cc0). Ascending jumps therefore run contact-free (ballistic, no glue) - the gate's reason-for-being is gone. The plane requirement is strict: Contact-without-plane is unrepresentable in retail. - SetPositionInternal-shaped commit (0x00515330, byte-read end-to-end, velocity-sign-FREE): contact purely from the transition's contact plane, HitGround on the airborne->walkable edge, HandleAllCollisions with unmodified impact velocity. Whole commit gated on Ok && candidateMoved (retail pc:283657 skips SetPositionInternal entirely when the candidate did not move) - a standing body's contact state is never re-derived, which is what keeps rest bit-stable (AD-41 updated). - Byte decodes: gate override state&0x800000=Sledding, zero branch state&0x20000=Inelastic, reflect strictly dot<0 - our port already had all three correct. Settle: real landings (>=0.25 m/s) bounce and decay geometrically; smaller impacts are consumed by retail's unconditional small-velocity zero, so standing never micro-bounces. Re-baselines documented in place: landing-survival pin measures decay post-settle; LiveCompare_Tick0/376 pin the new IsOnGround=false on zero-move ticks (captured true was the retired seed echo; tick 376's captured body carries an 11.8 m/s grounded velocity from the deleted get_state_velocity-overwrite era); de-overlap fixture now carries the plane real grounded bodies always have. New pins: LandingBounceSeedingTests (ascent no-seed, rest keeps contact, strict plane, slope 5% reversal + tangential preservation, Sledding override). Investigation + implementation record: docs/research/2026-07-30-landing-bounce-family.md. Complete Release suite: 10,031 passed / 5 skips / 0 failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
153 lines
5.9 KiB
C#
153 lines
5.9 KiB
C#
using System;
|
|
using System.Numerics;
|
|
using AcDream.Core.Physics;
|
|
using Xunit;
|
|
|
|
namespace AcDream.Core.Tests.Physics;
|
|
|
|
/// <summary>
|
|
/// 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).
|
|
/// </summary>
|
|
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<CellSurface>(), Array.Empty<PortalPlane>(), 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);
|
|
}
|
|
}
|