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

@ -628,12 +628,11 @@ public sealed class PhysicsBody
/// remaining — matches the observed hammering almost exactly.
///
/// Why this is safe to land now: the L.3c test predates the 2026-07-17
/// "local player animation-owned grounded movement" landing (R6).
/// PlayerMovementController.cs (~line 1742) zeroes Velocity.X/Y to 0
/// immediately before UpdatePhysicsInternal runs whenever animation root
/// motion drives the walk (the production graphical local-player path
/// since R6) — walking displacement comes from the animation Frame delta
/// applied directly to Position, not from integrating Velocity. Friction
/// "local player animation-owned grounded movement" landing (R6). Walking
/// displacement comes from the animation Frame delta applied directly to
/// Position, not from integrating Velocity, so ordinary root-motion-driven
/// walking never puts real XY speed into Velocity in the first place
/// (nothing writes it there — see the #265/#166 fix note below). Friction
/// decaying an already-zero horizontal Velocity is a no-op, so the L.3c
/// mechanism does not reproduce on that path. The `else` branch (no
/// animation root motion — headless/test-controller movers using
@ -642,6 +641,21 @@ public sealed class PhysicsBody
/// GroundedRootMotion_FrictionThreshold_DoesNotHammerLocomotionTests for
/// the regression pin on the root-motion path specifically.
///
/// #265/#166 RESOLVED (2026-07-30,
/// docs/research/2026-07-30-265-capture-bisect.md §9): until this date,
/// PlayerMovementController.cs's grounded-tick block ALSO hand-zeroed
/// Velocity.X/Y to exactly 0 every tick once OnWalkable for the
/// animation-root-motion case (regardless of why the body was grounded —
/// a fall, not just ordinary walking), discarding any residual landing
/// momentum before this very function ever got a chance to decay it, and
/// GroundNormal (the vector this function dots velocity against) had zero
/// production writers and silently defaulted to Vector3.UnitZ. Both gaps
/// are now closed: the grounded block no longer reconstructs Velocity for
/// the root-motion case, and PhysicsEngine.cs syncs GroundNormal from the
/// committed ContactPlane.Normal after every resolve. This function's
/// 0.25f threshold and Sledding overrides were always correctly ported;
/// they simply had nothing real to operate on until now.
///
/// AD-55 RESOLVED (Campaign P final physics slice, 2026-07-30; byte
/// decode in docs/research/2026-07-30-ts4-116-oracle-plan.md Addendum).
/// Raw bytes of <c>CPhysicsObj::calc_friction @ 0x0050ee70</c>'s

View file

@ -1300,6 +1300,18 @@ public sealed class PhysicsEngine
body.ContactPlane = ci.ContactPlane;
body.ContactPlaneCellId = ci.ContactPlaneCellId;
body.ContactPlaneIsWater = ci.ContactPlaneIsWater;
// #265/#166 (2026-07-30): retail CPhysicsObj::calc_friction
// (0x0050ee70) reads `this->contact_plane.Normal` directly off
// the object (see PhysicsBody.calc_friction's doc comment).
// acdream models that same field as the separate GroundNormal
// property so isolated unit tests can drive calc_friction
// without a full resolve, but nothing wrote it from a live
// resolve before now -- calc_friction always saw the Vector3.UnitZ
// default, i.e. every slope behaved like flat ground. Sync it
// here, at the SAME commit point that already publishes
// ContactPlane, so every caller (player, remote, ordinary,
// projectile) gets a real slope normal for free.
body.GroundNormal = ci.ContactPlane.Normal;
}
else if (ci.LastKnownContactPlaneValid)
{
@ -1307,10 +1319,16 @@ public sealed class PhysicsEngine
body.ContactPlane = ci.LastKnownContactPlane;
body.ContactPlaneCellId = ci.LastKnownContactPlaneCellId;
body.ContactPlaneIsWater = ci.LastKnownContactPlaneIsWater;
body.GroundNormal = ci.LastKnownContactPlane.Normal;
}
else
{
body.ContactPlaneValid = false;
// GroundNormal left unchanged/stale -- matches ContactPlane's
// own stale-retention pattern in this branch (comment above).
// calc_friction only reads it while OnWalkable, and OnWalkable
// cannot be true without a valid contact plane, so a stale
// value here is never observed.
}
// AP-10 (Campaign P Slice P4, 2026-07-30): retail SetPositionInternal

View file

@ -1865,20 +1865,46 @@ public sealed class PlayerMovementController
* tickDt;
}
if (_body.OnWalkable)
// #265/#166 (2026-07-30, docs/research/2026-07-30-265-capture-bisect.md
// §4): retail CPhysicsObj::UpdatePositionInternal (0x00512C30) composes
// BOTH channels every quantum -- the root-motion Frame just written into
// pmDelta.Origin above (commanded locomotion) AND the integrated physics
// Velocity (residual momentum: jump arcs, landing slides) -- via the SAME
// candidate position that PhysicsBody.UpdatePhysicsInternal's Euler step
// (calc_friction + v*dt, below) and the ResolveWithTransition sweep both
// see. This block used to hand-zero Velocity.X/Y to EXACTLY zero on every
// single grounded tick whenever animation root motion drives the walk (the
// production graphical local-player path since R6) -- regardless of why
// the body was OnWalkable. That discarded any horizontal momentum a fall
// or collision had just left on the body (retail settles it via
// calc_friction over subsequent ticks; PhysicsBody.GroundNormal is now
// synced to the real contact-plane normal by PhysicsEngine so calc_friction
// has real slope data to act on) before the integrator ever ran -- a mover
// that landed on a walkable roof/slope with residual horizontal velocity
// had that velocity vanish the very next tick and never moved again. Root
// motion still fully owns COMMANDED locomotion (walking/running
// displacement comes from pmDelta.Origin above, not from Velocity), so
// this does not reintroduce command- or packet-cadence-derived grounded
// translation -- it only stops DESTROYING whatever Velocity already holds.
// Ordinary walking is unaffected: Velocity is already ~0 while grounded
// with no fall/collision in flight (nothing else writes it), so removing
// this zero is a no-op on that path -- see
// GroundedRootMotion_FrictionThreshold_DoesNotHammerLocomotionTests
// (PhysicsBodyTests.cs) and Update_AnimationRootMotion_WalkSpeedUnaffected
// ByResidualVelocityFix (PlayerMovementControllerTests.cs).
//
// The headless/test-controller fallback below (no animation runtime --
// get_state_velocity's doc comment) is unchanged: it still directly
// writes the commanded state velocity into the body every grounded tick,
// exactly as before -- that model has no separate root-motion channel to
// compose with, so overwriting IS its correct per-tick behavior.
if (_body.OnWalkable && !hasAnimationRootMotion)
{
float savedWorldVz = _body.Velocity.Z;
if (hasAnimationRootMotion)
{
_body.Velocity = new Vector3(0f, 0f, savedWorldVz);
}
else
{
Vector3 stateVelocity = _motion.get_state_velocity();
_body.set_local_velocity(
new Vector3(stateVelocity.X, stateVelocity.Y, savedWorldVz),
autonomous: _body.LastMoveWasAutonomous);
}
Vector3 stateVelocity = _motion.get_state_velocity();
_body.set_local_velocity(
new Vector3(stateVelocity.X, stateVelocity.Y, savedWorldVz),
autonomous: _body.LastMoveWasAutonomous);
}
var preIntegratePos = _body.Position;