fix(physics): jump arc was zero — stop pre-seeding ContactPlane while airborne

Live diagnostic (extent=1.000, vz=9.09 — formula peak 4.21m) showed
the body's Velocity.Z stayed at ~9 m/s but Position.Z never
advanced past 66.000 even after 575 frames airborne. The collision
resolver was snapping the player back to ground every step.

Root cause: PhysicsEngine.ResolveWithTransition unconditionally
pre-seeded the Transition's CollisionInfo from body.ContactPlane
before each resolve (a slope-walking continuity hack). Once
airborne, that pre-seed makes Transition.CollisionInfo's
ContactPlaneValid stay true. Then in AdjustOffset's "Have a contact
plane" path, when collisionAngle > 0 (offset moving AWAY from the
plane = jumping up), the code calls Plane::snap_to_plane on the
offset which ZEROES the Z component for flat ground (Normal.Z=1,
plane.D=0 → snap_to_plane sets vec.z = 0). The horizontal X/Y
parts of the offset survived; vertical Z was destroyed every step.
Position.Z only ever got the gravity drift back down, so the
"jump" was literally a sub-frame upward blip followed by 575
frames of stuck-at-ground while gravity ate vz.

Retail's CTransition::init at retail address 0x509dd0
(named-retail line 271954) explicitly sets
contact_plane_valid = 0 at the start of every transition resolve.
ValidateWalkable then re-establishes it during the sweep when
the foot sphere bottom is within EPSILON of the terrain plane —
so for grounded motion the plane is set fresh per frame, and for
airborne motion no plane interferes.

Fix: only seed the contact plane when isOnGround is true.
Airborne resolves now start with no plane, so AdjustOffset
preserves the upward Z and the integrator's positional update
actually lands. Slope-walking continuity is preserved because
the seed still fires whenever the body is grounded.

Diagnostic logging stripped after the fix.

Tests stay 1222 green. Live verification pending.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-26 17:17:13 +02:00
parent 32583cdfe4
commit 5145938d06

View file

@ -398,12 +398,27 @@ public sealed class PhysicsEngine
if (isOnGround) if (isOnGround)
transition.ObjectInfo.State |= ObjectInfoState.Contact | ObjectInfoState.OnWalkable; transition.ObjectInfo.State |= ObjectInfoState.Contact | ObjectInfoState.OnWalkable;
// Seed the transition's CollisionInfo with the previous frame's // K-fix7 (2026-04-26): only seed the contact plane when the body
// contact plane (retail PhysicsObj field). Without this, every // is actually grounded. Pre-seeding while AIRBORNE caused
// ResolveWithTransition call starts with a fresh plane, AdjustOffset's // AdjustOffset's "Have a contact plane / Moving away from plane"
// "Have a contact plane" branch never fires, and slope projection // branch to fire on every jump step — which calls
// never happens. // Plane::snap_to_plane on the offset and ZEROES the Z component,
if (body is not null && body.ContactPlaneValid) // killing all upward jump motion (the body's Z velocity stayed
// ~9 m/s but Position.Z never advanced because every step's
// offset got snapped flat). Retail's CTransition::init at
// 0x509dd0 (named-retail line 271954) explicitly clears
// contact_plane_valid = 0 at the start of every transition
// resolve, then ValidateWalkable re-establishes it during the
// sweep when the sphere bottom is within EPSILON of the terrain
// plane — so for grounded motion the plane is set fresh every
// resolve, and for airborne motion no plane interferes.
//
// We KEEP the seeding when isOnGround for slope-walking
// continuity (the original concern that motivated the seed) —
// walking up a hill needs the previous step's slope to project
// movement properly. Airborne / jumping must start with no
// plane so AdjustOffset preserves Z.
if (isOnGround && body is not null && body.ContactPlaneValid)
{ {
transition.CollisionInfo.SetContactPlane( transition.CollisionInfo.SetContactPlane(
body.ContactPlane, body.ContactPlane,