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

@ -1144,20 +1144,32 @@ public class PlayerMovementControllerTests
Assert.False(controller.IsAirborne, "Should have landed");
// THE #265/#166 ACCEPTANCE BAR: the tick immediately after landing
// must NOT be hand-zeroed to exactly (0,0,0) -- the old bug. On flat
// ground dot(velocity, GroundNormal=(0,0,1)) = velocity.Z ~ 0 after
// the landing hand-zero, which is below calc_friction's 0.25f
// threshold, so friction DOES measurably engage here (unlike the
// specific captured roof geometry in
// Issue265SteepSlopeCaptureBisectTests, where it happens not to) --
// this is "survives, then decays," not "coasts forever."
// must NOT be hand-zeroed to exactly (0,0,0) -- the old bug. The
// landing-bounce rework (2026-07-30,
// docs/research/2026-07-30-landing-bounce-family.md) restores retail
// handle_all_collisions' reflect (v += -(v·n)(elasticity+1)·n,
// elasticity 0.05), so the first post-landing ticks are a micro-hop
// CHAIN: each ground contact reverses 5% of the impact's normal
// component and keeps the tangential -- the residual speed survives,
// and calc_friction engages once a hop's contact dot falls under the
// 0.25 AP-7 gate. "Survives, then decays" therefore measures a few
// ticks after touchdown, not the very first one (friction cannot run
// during the reflected rise -- that IS retail's landing bounce).
controller.Update(ObjectTick, new MovementInput());
float horizSpeedAfterLanding =
new Vector2(controller.BodyVelocity.X, controller.BodyVelocity.Y).Length();
Assert.True(horizSpeedAfterLanding > 0.01f,
$"Expected residual horizontal speed to survive the first post-landing tick; " +
$"got {horizSpeedAfterLanding} (launch speed was {horizSpeedAtLaunch})");
Assert.True(horizSpeedAfterLanding < horizSpeedAtLaunch,
"Expected friction to have begun decaying the residual speed, not leave it unchanged");
// Let the 5%-elasticity hop chain settle (v_z decays geometrically;
// a handful of ObjectTicks covers several hops), then require decay.
for (int i = 0; i < 12; i++)
controller.Update(ObjectTick, new MovementInput());
float horizSpeedSettled =
new Vector2(controller.BodyVelocity.X, controller.BodyVelocity.Y).Length();
Assert.True(horizSpeedSettled < horizSpeedAtLaunch,
$"Expected friction to decay the residual speed once the bounce chain settles; " +
$"got {horizSpeedSettled} vs launch {horizSpeedAtLaunch}");
}
}