fix(physics): #265 landing-bounce family - retail check_contact seed + velocity-free landing commit

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>
This commit is contained in:
Erik 2026-07-30 20:12:06 +02:00
parent 7fcc7db1d1
commit 2d611b2b01
10 changed files with 620 additions and 84 deletions

View file

@ -1167,37 +1167,63 @@ public sealed class PhysicsEngine
// in ValidateTransition runs for gravity movers (the player) and not floating props.
transition.ObjectInfo.MoverHasGravity = body?.HasGravity ?? false;
if (isOnGround)
transition.ObjectInfo.State |= ObjectInfoState.Contact | ObjectInfoState.OnWalkable;
// K-fix7 (2026-04-26): only seed the contact plane when the body
// is actually grounded. Pre-seeding while AIRBORNE caused
// AdjustOffset's "Have a contact plane / Moving away from plane"
// branch to fire on every jump step — which calls
// Plane::snap_to_plane on the offset and ZEROES the Z component,
// killing all upward jump motion.
// Landing-bounce family (#265, 2026-07-30,
// docs/research/2026-07-30-landing-bounce-family.md): the retail
// seed is CPhysicsObj::get_object_info (0x00511cc0) — a body in
// transient CONTACT is re-checked per transition by
// CPhysicsObj::check_contact (0x0050f5b0): contact HOLDS only while
// v · contact_plane.N <= ε (0.0002), i.e. the mover is not moving
// AWAY from its plane. A jump launch fails the check instantly, so
// the transition runs contact-free (no step-down glue, ballistic
// ascent, no contact plane found → SetPositionInternal clears
// CONTACT naturally). The failed-check branch seeds only the
// LAST-KNOWN contact plane (init_last_known_contact_plane) — plane
// context without contact state. This replaces the former
// isOnGround-driven seed (the "resolver reports ground during an
// ascending jump" divergence that forced the AD-25 landing gate).
//
// We KEEP the seeding when isOnGround for slope-walking + step-up
// continuity (the original concern that motivated the seed).
// BSP step_up needs ContactPlane on sub-step 1 to compute the
// correct lift direction; removing the seed breaks stair-walking
// at the last step (verified by A6.P3 slice 2 first attempt
// 2026-05-22, reverted in this commit). Retail's CTransition::init
// explicitly CLEARS contact_plane_valid; we deliberately diverge
// for step_up correctness.
//
// A6.P3 slice 2 (2026-05-22) — to close issue #96 (per-tick CP-write
// blowup) without breaking stair-walking, the no-op-if-unchanged
// guard inside CollisionInfo.SetContactPlane (TransitionTypes.cs:259)
// collapses redundant seeds (same plane every tick) to a true no-op.
// The seed still fires the function call but only counts as a write
// when the plane values actually change.
if (isOnGround && body is not null && body.ContactPlaneValid)
// K-fix7 lineage: pre-seeding a full contact plane while airborne
// made AdjustOffset's snap-to-plane zero jump Z — check_contact is
// retail's own version of that guard. Grounded walking (v·n ≈ 0)
// keeps the plane seed for slope/step-up continuity exactly as
// before (A6.P3 slice 2: SetContactPlane's no-op-if-unchanged guard
// still collapses redundant per-tick seeds).
// A contact WITHOUT a stored plane is unrepresentable in retail
// (init_contact_plane always accompanies the CONTACT seed), so the
// plane requirement here is strict: a body flagged Contact but with
// no committed plane (e.g. the tick after a placement that never
// swept) seeds nothing — its first moving resolve re-derives
// contact from the geometry it actually touches.
if (body is not null && body.InContact && body.ContactPlaneValid)
{
transition.CollisionInfo.SetContactPlane(
body.ContactPlane,
body.ContactPlaneCellId,
body.ContactPlaneIsWater);
// retail ε 0.000199999995f == PhysicsGlobals.EPSILON (0.0002f).
float awayRate = Vector3.Dot(body.Velocity, body.ContactPlane.Normal);
if (awayRate <= PhysicsGlobals.EPSILON)
{
transition.ObjectInfo.State |= ObjectInfoState.Contact;
if (body.OnWalkable)
transition.ObjectInfo.State |= ObjectInfoState.OnWalkable;
transition.CollisionInfo.SetContactPlane(
body.ContactPlane,
body.ContactPlaneCellId,
body.ContactPlaneIsWater);
}
else
{
// retail get_object_info failed-check branch:
// CTransition::init_last_known_contact_plane.
transition.CollisionInfo.LastKnownContactPlaneValid = true;
transition.CollisionInfo.LastKnownContactPlane = body.ContactPlane;
transition.CollisionInfo.LastKnownContactPlaneCellId = body.ContactPlaneCellId;
transition.CollisionInfo.LastKnownContactPlaneIsWater = body.ContactPlaneIsWater;
}
}
else if (body is null && isOnGround)
{
// Body-less callers (one-shot probes, tests) keep the legacy
// grounded seed — they have no velocity/plane to run
// check_contact against.
transition.ObjectInfo.State |= ObjectInfoState.Contact | ObjectInfoState.OnWalkable;
}
// Retail CPhysicsObj::get_object_info also seeds SlidingNormal when