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:
parent
7fcc7db1d1
commit
2d611b2b01
10 changed files with 620 additions and 84 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -2029,50 +2029,70 @@ public sealed class PlayerMovementController
|
|||
_prevPhysicsPos = oldTickEndPos;
|
||||
_currPhysicsPos = _body.Position;
|
||||
|
||||
// SetPositionInternal contact determination (pc:283468-510). acdream's resolver
|
||||
// reports IsOnGround even during an UPWARD jump (it always step-downs), so the
|
||||
// contact-plane intent stays gated by Velocity.Z<=0 (documented adaptation AD-25): a
|
||||
// jump stays airborne until it descends. Determined BEFORE handle_all_collisions so the
|
||||
// landing state is committed before any reflect — this ordering plus the ungated
|
||||
// small-velocity-zero (Slice 1a) is what retires AD-25's micro-bounce death spiral
|
||||
// (the old code reflected FIRST, so the reflected +Z defeated the landing gate).
|
||||
// Landing-bounce family (#265, 2026-07-30,
|
||||
// docs/research/2026-07-30-landing-bounce-family.md): retail
|
||||
// SetPositionInternal (0x00515330) commits contact PURELY from the
|
||||
// transition's contact plane — no velocity-sign gate, no velocity
|
||||
// zeroing. The former AD-25 gate (Velocity.Z<=0) existed because
|
||||
// the resolver glued ascending movers to the ground; the
|
||||
// check_contact seed (PhysicsEngine, retail 0x0050f5b0) now stops
|
||||
// that at the source — an ascending jump finds no contact plane and
|
||||
// goes airborne naturally. The former Velocity.Z hand-zero
|
||||
// explicitly defeated handle_all_collisions' landing reflect;
|
||||
// deleting it restores retail's landing bounce
|
||||
// (v += -(v·n)(elasticity+1)·n, DEFAULT_ELASTICITY 0.05): the flat
|
||||
// landing pop, the downhill bounce chain, and the uphill
|
||||
// into-slope velocity kill all come from that reflect.
|
||||
//
|
||||
// Retail reaches SetPositionInternal only when the transition
|
||||
// succeeded (CPhysicsObj::transition 0x00512DC0 discards the
|
||||
// CTransition on find_valid_position failure) — contact state stays
|
||||
// untouched on failure frames. fsf-wedge frames are OK frames
|
||||
// (ValidateTransition manufactures the UP contact), so the fsf>1
|
||||
// bleed below remains reachable exactly as before.
|
||||
// The WHOLE commit is additionally gated on candidateMoved: retail
|
||||
// UpdateObjectInternal (pc:283657) only runs the transition +
|
||||
// SetPositionInternal when the integrated candidate MOVED — a
|
||||
// standing body's contact state is never re-derived (a zero-move
|
||||
// resolve cannot "find" a contact plane because no sweep runs; the
|
||||
// old code masked this by echoing the caller's isOnGround back
|
||||
// through the seeded oi flags). This same gate is what keeps a
|
||||
// post-bleed no-move frame from re-zeroing the rebuilding gravity
|
||||
// velocity (#182).
|
||||
bool landedThisQuantum = false;
|
||||
if (resolveResult.IsOnGround && _body.Velocity.Z <= 0f)
|
||||
if (resolveResult.Ok && candidateMoved)
|
||||
{
|
||||
bool wasAirborne = !_body.OnWalkable;
|
||||
_body.TransientState |= TransientStateFlags.Contact | TransientStateFlags.OnWalkable;
|
||||
_body.calc_acceleration();
|
||||
if (resolveResult.InContact)
|
||||
_body.TransientState |= TransientStateFlags.Contact;
|
||||
else
|
||||
_body.TransientState &= ~TransientStateFlags.Contact;
|
||||
_body.calc_acceleration(); // pc:283442 (post-contact-bit)
|
||||
|
||||
// Stop the fall on landing (retail settles the into-ground component via
|
||||
// calc_friction next frame; the hand-zero avoids a one-frame floor dip and makes
|
||||
// handle_all_collisions' landing reflect a no-op — dot(v,n)=0).
|
||||
if (_body.Velocity.Z < 0f)
|
||||
_body.Velocity = new Vector3(_body.Velocity.X, _body.Velocity.Y, 0f);
|
||||
|
||||
if (wasAirborne)
|
||||
if (resolveResult.InContact && resolveResult.OnWalkable)
|
||||
{
|
||||
// R4-V5 → R5-V5: retail order — minterp then moveto
|
||||
// (MovementManager::HitGround 0x00524300). Re-arms a moveto suspended by the
|
||||
// airborne UseTime contact gate. LeaveGround has NO moveto side (§2e).
|
||||
Movement.HitGround();
|
||||
landedThisQuantum = true;
|
||||
bool wasAirborne = !_body.OnWalkable;
|
||||
_body.TransientState |= TransientStateFlags.OnWalkable;
|
||||
if (wasAirborne)
|
||||
{
|
||||
// R4-V5 → R5-V5: retail order — minterp then moveto
|
||||
// (MovementManager::HitGround 0x00524300). Re-arms a
|
||||
// moveto suspended by the airborne UseTime contact gate.
|
||||
// LeaveGround has NO moveto side (§2e).
|
||||
Movement.HitGround();
|
||||
landedThisQuantum = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Airborne: jumping up (IsOnGround but v.z>0) OR no ground found.
|
||||
_body.TransientState &= ~(TransientStateFlags.Contact | TransientStateFlags.OnWalkable);
|
||||
_body.calc_acceleration();
|
||||
}
|
||||
else
|
||||
{
|
||||
_body.TransientState &= ~TransientStateFlags.OnWalkable;
|
||||
}
|
||||
_body.calc_acceleration(); // pc:283475/283490 (set_on_walkable tail)
|
||||
|
||||
// handle_all_collisions (0x00514780): reflect the into-surface velocity (fsf≤1) or
|
||||
// ZERO it entirely (fsf>1 — THE airborne-stuck fix). The Stationary* bit round-trip is
|
||||
// owned by the Core resolve writeback. Restores retail's should_reflect rule; on a
|
||||
// landing the Velocity.Z hand-zero above makes the reflect a no-op (no micro-bounce).
|
||||
// Gated on candidateMoved (retail SetPositionInternal is only reached when the candidate
|
||||
// moved) so a no-move frame doesn't re-zero the gravity velocity rebuilding after a bleed.
|
||||
if (candidateMoved)
|
||||
{
|
||||
// handle_all_collisions (0x005154FE): reflect the into-surface
|
||||
// velocity (fsf≤1 — v += -(v·n)(elasticity+1)·n, the landing
|
||||
// bounce) or ZERO it entirely (fsf>1 — THE airborne-stuck
|
||||
// fix). The Stationary* bit round-trip is owned by the Core
|
||||
// resolve writeback.
|
||||
PhysicsObjUpdate.HandleAllCollisions(
|
||||
_body,
|
||||
resolveResult.CollisionNormalValid, resolveResult.CollisionNormal,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue