fix(physics): #265/#166 - stop zeroing grounded residual velocity, wire GroundNormal

Capture bisect (docs/research/2026-07-30-265-capture-bisect.md, mined
from artifacts/matrix-session2-resolve.jsonl records 3415-3434) traced
#265's lost roof slides / permanent landing freeze and #166's missing
downhill sled to a pre-existing (2026-07-20, ten days before Campaign P
- not a regression) mechanism in PlayerMovementController.cs's grounded
quantum block: it hand-zeroed Velocity.X/Y to exactly zero every tick
once OnWalkable whenever animation root motion drives the walk (the
production graphical local-player path), discarding any residual
horizontal momentum a fall left on the body before calc_friction
(AP-7/AD-55, already correctly ported) or PhysicsBody.
UpdatePhysicsInternal's Euler integrator ever got a chance to act on it.

Two changes:

1. PhysicsEngine.cs now syncs body.GroundNormal (the vector
   calc_friction dots velocity against, per retail
   CPhysicsObj::calc_friction 0x0050ee70's `contact_plane.Normal` read)
   from the committed ContactPlane.Normal at the same commit point that
   already publishes ContactPlane. GroundNormal had zero production
   writers before this and silently defaulted to Vector3.UnitZ forever
   - even surviving velocity would have been tested against a fake
   flat-ground normal on any real slope. Core-level, so player, remote,
   ordinary, and projectile movers all benefit uniformly.

2. PlayerMovementController.cs's grounded block no longer reconstructs
   Velocity at all for the animation-root-motion case (only the
   headless/test-controller get_state_velocity fallback still does,
   unchanged). Root motion continues to fully own commanded locomotion;
   this only stops destroying whatever Velocity already holds, letting
   it compose with root motion through the same ResolveWithTransition
   sweep exactly as retail's CPhysicsObj::UpdatePositionInternal
   composes both channels.

Symptom (a), the uphill-jump bounce, traces to a SEPARATE, byte-exact
(re-verified against acclient_2013_pseudo_c.txt:282647-282760),
already-closed retail mechanism (AD-25, PhysicsObjUpdate.
HandleAllCollisions's shouldReflect gate) - confirmed orthogonal to this
fix, not addressed here (see the research doc's as-fixed addendum §9.5).

Issue265SteepSlopeCaptureBisectTests.cs gains a composed harness
(ReplayRealRoofLandingComposed) mirroring PlayerMovementController.cs's
per-tick composition against Core types only, proving: the old model
reproduces the mined freeze exactly; the new model survives the landing
and slides continuously (the real captured geometry glides at constant
velocity per retail's own dot>=0.25 early-return - AP-7); a synthetic
dot<0.25 case shows genuine exponential decay via calc_friction; and a
synthetic uphill-bounce case proves the fix changes nothing about
HandleAllCollisions's reflection decision.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-30 18:45:05 +02:00
parent 61e959169b
commit 06c76009f1
5 changed files with 673 additions and 36 deletions

View file

@ -502,19 +502,29 @@ public sealed class PhysicsBodyTests
{
// Campaign P Slice P2 research finding: the reverted 2026-04-30 L.3c
// regression (forward locomotion 3 -> 0.16 m/s) cannot reproduce on
// the production graphical local-player path post-R6, because
// PlayerMovementController zeroes Velocity.X/Y to exactly zero every
// tick BEFORE UpdatePhysicsInternal/calc_friction runs whenever
// animation root motion drives the walk (walking displacement comes
// from the animation Frame delta applied directly to Position, not
// from integrating Velocity). This test pins that specific state at
// the PhysicsBody level (the only file this slice may change):
// Velocity.XY == 0 on flat ground is IDENTICAL after calc_friction
// whether the threshold is the old 0.0 or the new retail 0.25 --
// friction has nothing to hammer because there is no horizontal
// velocity for it to act on. Only the residual vertical (gravity)
// component may be affected by the normal-removal step, exactly as
// retail's own contact handling expects.
// the production graphical local-player path post-R6, because ordinary
// root-motion-driven walking (no fall/collision in flight) never puts
// real horizontal speed into Velocity in the first place -- walking
// displacement comes from the animation Frame delta applied directly
// to Position, not from integrating Velocity, and nothing else writes
// Velocity.XY during ordinary grounded locomotion. This test pins that
// specific state at the PhysicsBody level (the only file this slice
// may change): Velocity.XY == 0 on flat ground is IDENTICAL after
// calc_friction whether the threshold is the old 0.0 or the new
// retail 0.25 -- friction has nothing to hammer because there is no
// horizontal velocity for it to act on. Only the residual vertical
// (gravity) component may be affected by the normal-removal step,
// exactly as retail's own contact handling expects.
//
// #265/#166 (2026-07-30): PlayerMovementController.cs USED TO also
// hand-zero Velocity.X/Y to exactly zero every grounded tick for the
// animation-root-motion case (belt-and-suspenders on top of the "walk
// speed never writes it" fact above) -- that zero is now REMOVED (see
// docs/research/2026-07-30-265-capture-bisect.md §9), because it also
// discarded real residual landing momentum a fall left behind. This
// test's own premise (Velocity.XY already 0, no walk speed in it) is
// unaffected either way -- it exercises calc_friction in isolation and
// never depended on the removed zero.
var body = MakeGrounded();
body.GroundNormal = Vector3.UnitZ;
body.Friction = 0.95f;