test(physics): #265/#166 - Runtime-level walk-speed and landing-survival pins

Two new PlayerMovementController-level tests, exercising the real
production controller (not just the Core-level composed model in the
prior commit):

- Update_AnimationRootMotion_WalkSpeedUnaffectedByResidualVelocityFix:
  ordinary root-motion walking (no fall/collision in flight) advances by
  exactly the authored per-tick delta for 30 ticks with BodyVelocity
  staying exactly zero throughout - the fix is a complete no-op for the
  common case, pinning the L.3c hazard
  (claude-memory/project_physics_collision_digest.md's DO-NOT-RETRY
  table) at the Runtime level alongside the existing Core-level
  GroundedRootMotion_FrictionThreshold_DoesNotHammerLocomotionTests pin
  (unmodified, still green).

- Update_RunningJumpLandsOnFlatGround_ResidualVelocitySurvivesAndDecays_NotFrozen:
  a real charged running jump lands on flat ground and its residual
  horizontal speed survives the first post-landing tick, then measurably
  decays (dot(velocity, (0,0,1)) ~ 0 < 0.25, so friction engages here,
  unlike the sloped roof capture where it doesn't).

Building the second test surfaced two genuinely separate, already-
correctly-scoped mechanisms unrelated to #265/#166, requiring no
production change: MotionInterpreter.LeaveGround (CMotionInterp::
LeaveGround 0x00528b00, R3-W4/J7/J8) recomputes velocity from the
CURRENT interpreted command on the grounded->airborne edge tick, so the
test holds Forward for one extra tick before releasing it; and
MotionInterpreter.ApplyCurrentMovementInterpreted's AP-77 "animation-less
/headless movement fallback" (already correctly scoped in the
divergence register) independently rewrites grounded velocity when no
DefaultSink is wired, so the test wires a minimal
FakeAnimationDispatchSink to match production's always-wired sink.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-30 18:45:30 +02:00
parent 06c76009f1
commit 9910838fa4

View file

@ -1033,4 +1033,131 @@ public class PlayerMovementControllerTests
WeenieError result = controller.Motion.jump_is_allowed(1.0f, out _);
Assert.Equal(WeenieError.GeneralMovementFailure, result); // 0x47
}
// ════════════════════════════════════════════════════════════════════
// #265/#166 (2026-07-30): grounded residual-velocity fix.
// docs/research/2026-07-30-265-capture-bisect.md §4 traced the mined
// freeze to this file's grounded-tick block hand-zeroing Velocity.X/Y
// to exactly zero every tick once OnWalkable, whenever
// AttachAnimationRootMotionSource is wired (the production graphical
// local-player path). The fix removed that zero for the root-motion
// case; these tests pin (a) ordinary walking is unaffected (Velocity
// is already ~0 with no fall/collision in flight, so removing the zero
// is a no-op) and (b) a genuine landing with residual horizontal
// velocity now survives the contact commit and decays instead of
// freezing solid the very next tick.
// ════════════════════════════════════════════════════════════════════
[Fact]
public void Update_AnimationRootMotion_WalkSpeedUnaffectedByResidualVelocityFix()
{
var controller = new PlayerMovementController(MakeFlatEngine());
var start = new Vector3(96f, 96f, 50f);
controller.SetPosition(start, 0x0001);
controller.Yaw = 0f;
// Fixed per-tick local-forward delta, matching
// Update_AttachedAnimationRootDelta_DrivesGroundedBodyAtObjectScale's
// established pattern: root motion alone drives displacement.
controller.AttachAnimationRootMotionSource((_, frame) =>
frame.Origin = new Vector3(0f, 0.1f, 0f));
// SetPosition zeros Velocity and there is no fall/jump in this
// scenario, so Velocity must stay exactly zero for the whole walk --
// the #265/#166 fix (no longer reconstructing Velocity here) is a
// complete no-op on this path. Each admitted quantum must advance by
// exactly the authored root-motion delta, unperturbed by friction
// acting on a (zero) Velocity.
Vector3 prevPos = controller.Position;
for (int i = 0; i < 30; i++)
{
var result = controller.Update(ObjectTick, new MovementInput(Forward: true));
float advance = Vector3.Distance(result.Position, prevPos);
Assert.Equal(0.1f, advance, precision: 4);
prevPos = result.Position;
}
Assert.Equal(0f, controller.BodyVelocity.X, precision: 5);
Assert.Equal(0f, controller.BodyVelocity.Y, precision: 5);
}
/// <summary>
/// Minimal <see cref="IInterpretedMotionSink"/> so
/// <c>MotionInterpreter.ApplyCurrentMovementInterpreted</c> takes its
/// real dispatch branch (<c>DefaultSink is not null</c>) instead of the
/// AP-77 animation-less/headless fallback, which independently rewrites
/// grounded <c>PhysicsObj.Velocity</c> from <c>get_state_velocity()</c>
/// on every <c>HitGround</c>/<c>LeaveGround</c> re-apply -- a SEPARATE,
/// already-registered, out-of-scope mechanism (register row AP-77) that
/// would otherwise erase the #265/#166 residual velocity this test
/// targets purely because no sink was wired, not because of anything
/// this change touches. Production (<c>GameWindow</c>) always wires a
/// real sink, so this fake sink is what makes the test representative
/// of the production graphical path instead of the headless fallback.
/// </summary>
private sealed class FakeAnimationDispatchSink : IInterpretedMotionSink
{
public bool ApplyMotion(uint motion, float speed) => true;
public bool StopMotion(uint motion) => true;
}
[Fact]
public void Update_RunningJumpLandsOnFlatGround_ResidualVelocitySurvivesAndDecays_NotFrozen()
{
var engine = MakeFlatEngine();
var controller = new PlayerMovementController(engine);
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
controller.Yaw = 0f;
controller.Motion.DefaultSink = new FakeAnimationDispatchSink();
// No root-motion displacement contributed -- isolates the residual
// Velocity channel exactly like the mined #265 capture (no key held
// once airborne / at the instant of landing).
controller.AttachAnimationRootMotionSource((_, _) => { });
// Charged running jump: forward + full jump charge, then release.
controller.Update(1.0f, new MovementInput(Forward: true, Jump: true));
controller.Update(0.016f, new MovementInput(Forward: true, Jump: false));
// `jump()` clears OnWalkable immediately (section 1, above), but the
// ONE-TIME `MotionInterpreter.LeaveGround()` recompute-and-overwrite
// (retail CMotionInterp::LeaveGround 0x00528b00, R3-W4/J7/J8 --
// unrelated to #265/#166, not touched by this change) only fires on
// the grounded->airborne EDGE inside the FIRST admitted quantum tick
// afterward, and it reads whatever forward command is interpreted
// AT THAT MOMENT. Keep Forward held for that one tick so
// GetLeaveGroundVelocity() captures the real running-jump velocity;
// only release it afterward, isolating the #265/#166 residual-
// velocity question from this separate, pre-existing edge timing.
controller.Update(0.05f, new MovementInput(Forward: true));
Assert.True(controller.IsAirborne);
float horizSpeedAtLaunch =
new Vector2(controller.BodyVelocity.X, controller.BodyVelocity.Y).Length();
Assert.True(horizSpeedAtLaunch > 0.5f,
$"Expected a running jump to carry forward horizontal velocity, got {horizSpeedAtLaunch}");
// Release the forward key for the rest of the flight (matching the
// real capture's "no key held" freeze scenario) and fall back to the
// ground (DefaultJumpVz flight time ~2s at 50ms steps).
for (int i = 0; i < 50 && controller.IsAirborne; i++)
controller.Update(0.05f, new MovementInput());
Assert.False(controller.IsAirborne, "Should have landed");
// THE #265/#166 ACCEPTANCE BAR: the tick immediately after landing
// must NOT be hand-zeroed to exactly (0,0,0) -- the old bug. On flat
// ground dot(velocity, GroundNormal=(0,0,1)) = velocity.Z ~ 0 after
// the landing hand-zero, which is below calc_friction's 0.25f
// threshold, so friction DOES measurably engage here (unlike the
// specific captured roof geometry in
// Issue265SteepSlopeCaptureBisectTests, where it happens not to) --
// this is "survives, then decays," not "coasts forever."
controller.Update(ObjectTick, new MovementInput());
float horizSpeedAfterLanding =
new Vector2(controller.BodyVelocity.X, controller.BodyVelocity.Y).Length();
Assert.True(horizSpeedAfterLanding > 0.01f,
$"Expected residual horizontal speed to survive the first post-landing tick; " +
$"got {horizSpeedAfterLanding} (launch speed was {horizSpeedAtLaunch})");
Assert.True(horizSpeedAfterLanding < horizSpeedAtLaunch,
"Expected friction to have begun decaying the residual speed, not leave it unchanged");
}
}