From 9910838fa44d53de33644779f0fd305eb9a8cfd7 Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 30 Jul 2026 18:45:30 +0200 Subject: [PATCH] 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 --- .../Gameplay/PlayerMovementControllerTests.cs | 127 ++++++++++++++++++ 1 file changed, 127 insertions(+) diff --git a/tests/AcDream.Runtime.Tests/Gameplay/PlayerMovementControllerTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/PlayerMovementControllerTests.cs index 73edd6db..1fa15918 100644 --- a/tests/AcDream.Runtime.Tests/Gameplay/PlayerMovementControllerTests.cs +++ b/tests/AcDream.Runtime.Tests/Gameplay/PlayerMovementControllerTests.cs @@ -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); + } + + /// + /// Minimal so + /// MotionInterpreter.ApplyCurrentMovementInterpreted takes its + /// real dispatch branch (DefaultSink is not null) instead of the + /// AP-77 animation-less/headless fallback, which independently rewrites + /// grounded PhysicsObj.Velocity from get_state_velocity() + /// on every HitGround/LeaveGround 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 (GameWindow) 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. + /// + 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"); + } }