acdream/docs/research/2026-07-30-landing-bounce-family.md
Erik 2d611b2b01 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>
2026-07-30 20:12:06 +02:00

196 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.