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

@ -0,0 +1,196 @@
# The landing bounce family — retail bounce vs ground vs slide (investigation, report-only)
**Date:** 2026-07-30 · **Status:** IMPLEMENTED (same day — see §Implementation)
**Symptoms (user, live gate):** (1) downhill jumps glide instead of bouncing;
(2) flat-ground jumps at speed/height don't bounce; (3) uphill jumps get stuck
in weird animations, flapping and gliding. Speed (#266) and roof slide
(#265's freeze) are fixed and unaffected.
## The retail mechanism (decomp, read end-to-end this session)
Three functions compose the whole behavior:
### 1. The floor-touch dual record (plane handler, 0x0050d100-0x0050d30c)
Touching a floor plane records **two independent facts**:
```c
if (step_down || !(state & CONTACT) || is_valid_walkable(plane))
set_contact_plane(plane) // grounding fact
if (!(state & CONTACT) && !step_down) {
set_collision_normal(plane.N); // collision fact
collided_with_environment = 1;
}
```
A landing (not already in contact, not a step-down probe) is BOTH a contact
AND an environment collision carrying the floor normal. Ordinary walking
(already in contact / step-down glue probes) records only the contact —
that is why walking never bounces.
**acdream's transition already ports this faithfully**
(`TransitionTypes.cs:3410-3415``!oi.Contact && !sp.StepDown`
`SetCollisionNormal` + `CollidedWithEnvironment = true`).
### 2. SetPositionInternal (0x00515330, read fully — VELOCITY-SIGN-FREE)
```
contact = collision_info.contact_plane_valid (no velocity test)
on_walkable = contact && contact_plane.N.z >= floor_z (set_on_walkable → HitGround/LeaveGround)
handle_all_collisions(collision_info, prevContact, prevOnWalkable) ← velocity UNMODIFIED
```
There is **no `Velocity.Z <= 0` landing gate and no velocity zeroing**
anywhere in retail's commit. Contact is a per-frame fact from the
transition's contact plane; the bounce is the velocity reflect; they are
independent and coexist — you can be "landed" this frame AND carry
reflected +Z that lifts you off next frame. That IS the bounce chain.
(Our Core `PhysicsObjUpdate.CommitSetPositionTransition` is already a
faithful port of this function — used by teleport/remote placement, NOT by
the local player's per-tick path.)
### 3. handle_all_collisions (0x00514780) + elasticity
For fsf≤1, should-reflect (NOT(was-walkable AND still-walkable) or the
garbled state-flag override), valid collision normal, and `v·n < 0`:
```
v += -(v·n) · (elasticity + 1) · n // pc:282712
```
`DEFAULT_ELASTICITY = 0.05` (byte constant @0x007c6a7c; ctor writes at
0x005124d3/0x0051d537; `set_elasticity` clamps to [0, 0.1]). So every
landing reverses 5% of the impact's normal component and keeps the full
tangential component:
- **Flat ground at speed/height:** v=(6,0,7) → v'=(6,0,+0.35) — forward
carry plus a visible pop at speed. Symptom (2).
- **Downhill:** reflect is off the SLOPE normal — each contact pops the body
off-slope while tangential speed persists → contact/airborne chain =
the characteristic downhill bounce. Symptom (1).
- **Uphill:** the reflect kills the into-slope component at impact, contact
stands, HitGround fires once, land animation plays. Symptom (3)'s clean
retail counterpart.
## Why acdream glides/flaps instead (the adaptation stack)
`PlayerMovementController.cs:2032-2079` (the per-tick commit) replaces
retail's SetPositionInternal with a hand-rolled block:
1. **AD-25 landing gate:** `if (resolveResult.IsOnGround && Velocity.Z <= 0)`
— needed because *our resolver reports IsOnGround even during an UPWARD
jump (it always step-downs)*. Retail has no such gate: an ascending mover
simply finds no contact plane (it moves away from it; the touch test
fails), so contact clears naturally.
2. **The bounce killer:** `if (Velocity.Z < 0) Velocity.Z = 0` on landing,
whose comment says its purpose plainly: *"makes handle_all_collisions'
landing reflect a no-op — dot(v,n)=0."* This retired the old
"micro-bounce death spiral" — but that spiral was caused by our OWN
gate (reflected +Z defeating the `Velocity.Z<=0` landing test), not by
the reflect being wrong. The workaround deleted retail's legitimate
bounce.
3. With the reflect suppressed, the new #265 residual-velocity fix correctly
preserves landing momentum — which now SLIDES via calc_friction instead
of bouncing. Hence "I glide but that's incorrect."
4. **Uphill flap:** during the up-leg our resolver glues to the slope
(IsOnGround true) while the gate refuses to ground (v.z > 0) →
Contact/OnWalkable and HitGround/LeaveGround edges cycle against the
animation state machine → "weird animations, flapping and gliding."
## Hypotheses (ranked)
1. **H1 (root, high confidence — every link read this session):** the
AD-25 landing gate + Velocity.Z hand-zero must be REPLACED by retail's
SetPositionInternal semantics, which requires first fixing the underlying
resolver divergence: **the transition must not produce a contact plane
for a mover ascending away from the ground** (retail's step-down/touch
conditions do this naturally; ours "always step-downs"). With that fixed,
route the per-tick commit through the already-ported
`CommitSetPositionTransition` and delete the hand-rolled block — reflect,
contact, HitGround/LeaveGround, and land animation then compose exactly
as retail.
- Falsify by: cdb trace on retail (bp SetPositionInternal +
handle_all_collisions, dump v before/after while jumping downhill) —
expect unmodified impact v entering, 5% normal reversal exiting.
2. **H2 (contributing detail):** the garbled `state & <mush>` override in
handle_all_collisions' gate (our port maps it to Sledding) and the
`0x20000` Inelastic mapping need byte-decode confirmation before the
rework — a wrong flag here changes when reflects fire while grounded.
3. **H3 (animation-side residual):** if flap persists after H1, the
MotionInterp land/fall transition (LandJump vs falling-hold) has its own
gate to audit — deferred until H1 is in.
## What we've ruled out
- The transition's landing dual-record being missing — ours is faithful
(TransitionTypes.cs:3410).
- HandleAllCollisions' reflect math/elasticity — ported correctly
(PhysicsObjUpdate.cs:198, elasticity 0.05 default present).
- The #265 residual-velocity fix being wrong — it exposed the missing
bounce; it didn't cause it.
## Recommended next step
Approve H1 for implementation: (a) byte-decode the two garbled flags (H2)
first; (b) find + port retail's exact ascent/step-down gating in the
transition (the one remaining unread mechanism); (c) cut the per-tick commit
over to `CommitSetPositionTransition`; (d) re-run the roof/downhill/flat/
uphill matrix live. Optional pre-implementation confirmation: the H1 cdb
trace against live retail.
## What this is NOT
Not a missing-elasticity port and not a missing collision-record — both
exist and are faithful; the bounce is suppressed by our own landing-commit
adaptation (AD-25 family), whose reason-for-being is the resolver's
ascent-glue divergence.
## Implementation (2026-07-30, user-approved)
All three retail mechanisms are now live; the AD-25 adaptation stack is
deleted:
1. **check_contact seeding** (`PhysicsEngine.ResolveWithTransition`): a body
in transient CONTACT seeds the transition's contact state ONLY while
`v · contactPlane.N <= ε` (0.0002 = PhysicsGlobals.EPSILON, retail
0x0050f5b0); a failing body seeds the last-known plane alone (retail
get_object_info's init_last_known_contact_plane branch). The plane
requirement is strict — Contact-without-plane is unrepresentable in
retail. Body-less callers keep the legacy isOnGround seed (test rigs).
2. **SetPositionInternal-shaped commit** (`PlayerMovementController`): the
`Velocity.Z <= 0` landing gate and the landing `Velocity.Z = 0` hand-zero
are DELETED. Contact commits purely from `resolveResult.InContact` /
`OnWalkable`, HitGround fires on the airborne→walkable edge, and
`HandleAllCollisions` runs with the UNMODIFIED impact velocity — the 5%
elasticity reflect is live. The whole commit is gated on
`resolveResult.Ok && candidateMoved` (retail runs SetPositionInternal
only when the transition succeeded AND the candidate moved — pc:283657;
AD-41's row updated accordingly). Zero-move frames leave contact state
untouched (this is what keeps a standing body stable: a zero-move
resolve cannot re-derive a plane because no sweep runs).
3. **Byte decodes** (this doc's H2): the handle_all_collisions gate override
is `state & 0x800000` = Sledding; the zero branch is `state & 0x20000` =
Inelastic; the reflect fires strictly on `dot < 0` (`test ah, 5; jp`).
Our port had all three correct already — no change.
Settle behavior: a real landing (|v| ≥ 0.25 m/s) bounces at 5% and the hop
chain decays geometrically; sub-0.25 m/s impacts are consumed by retail's
unconditional small-velocity zero (PhysicsBody.UpdatePhysicsInternal), so a
standing body never micro-bounces. calc_acceleration turns gravity off for
Contact+OnWalkable bodies, which is what makes rest bit-stable.
Test re-baselines (each documented in place): the landing-survival pin now
measures decay after the hop chain settles; `LiveCompare_Tick0/376` pin the
new IsOnGround=false on their zero-move ticks (the captured `true` was the
retired seed echo — tick 376's captured body even carries an 11.8 m/s
grounded velocity from the deleted get_state_velocity-overwrite era);
`RemoteDeOverlapMechanismTests.GroundedBody` now carries the plane a real
grounded body always has (the big-creature 1.80 m expectation was calibrated
against the unrepresentable flags-without-plane fixture; production settles
at 1.58 m, unchanged before/after). New pins:
`LandingBounceSeedingTests` (ascent no-seed, rest keeps-contact, strict
plane, slope 5% reversal + tangential preservation, Sledding override).
Verification: complete Release suite 10,031 passed / 5 skips / 0 failures.
Live gate (downhill bounce chain, flat-ground pop, uphill clean landing,
roof slide intact, walking intact) pends the user's next session.