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:
parent
06c76009f1
commit
9910838fa4
1 changed files with 127 additions and 0 deletions
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue