acdream/tests/AcDream.Runtime.Tests/Gameplay/PlayerMovementControllerTests.cs

1243 lines
53 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
using AcDream.Runtime.Gameplay;
using AcDream.Runtime.Physics;
namespace AcDream.Runtime.Tests.Gameplay;
public class PlayerMovementControllerTests
{
private static float ObjectTick => PhysicsBody.MinQuantum + 0.001f;
private static PhysicsEngine MakeFlatEngine()
{
var engine = new PhysicsEngine();
var heights = new byte[81];
Array.Fill(heights, (byte)50);
var heightTable = new float[256];
for (int i = 0; i < 256; i++) heightTable[i] = i * 1f;
var terrain = new TerrainSurface(heights, heightTable);
engine.AddLandblock(0xA9B4FFFFu, terrain, Array.Empty<CellSurface>(),
Array.Empty<PortalPlane>(), worldOffsetX: 0f, worldOffsetY: 0f);
return engine;
}
[Fact]
public void PerformMovement_ReactivatesCanonicalClock_ExceptForStaticObject()
{
var clock = new RetailObjectQuantumClock();
var controller = new PlayerMovementController(MakeFlatEngine(), clock);
clock.Deactivate();
controller.ApplyPhysicsState(PhysicsStateFlags.None);
controller.Movement.PerformMovement(new MovementStruct
{
Type = (MovementType)99,
});
Assert.True(clock.IsActive);
clock.Deactivate();
controller.ApplyPhysicsState(PhysicsStateFlags.Static);
controller.Movement.PerformMovement(new MovementStruct
{
Type = (MovementType)99,
});
Assert.False(clock.IsActive);
}
[Fact]
public void Update_NoInput_PositionUnchanged()
{
var engine = MakeFlatEngine();
var controller = new PlayerMovementController(engine);
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
var result = controller.Update(0.016f, new MovementInput());
Assert.Equal(96f, result.Position.X, precision: 1);
Assert.Equal(96f, result.Position.Y, precision: 1);
}
// ── Indoor-flap root cause: resting-body bit-stability ────────────────────
//
// The indoor render "flap" (textures battling at the cottage doorway) is
// portal-flood membership instability. PortalVisibilityBuilder.Build is a
// proven-deterministic pure function, so the membership can only flip if its
// INPUT (the camera eye, derived from the player RenderPosition) varies.
// Live 6-dp capture (pvinput.log:54) shows the player RenderPosition carries
// a perpetual ~1-ULP flicker at rest (Z 94.000000 <-> 93.999992 — exactly one
// float mantissa step). ComputeRenderPosition is Vector3.Lerp(_prevPhysicsPos,
// _currPhysicsPos, alpha), and Lerp(a, a, t) == a exactly, so a jittering
// RenderPosition at rest means the physics body's resting Position is NOT
// bit-stable between ticks. Retail's authoritative local position is bit-stable
// at rest (validate_transition -> kill_velocity on every grounded contact), so
// retail never flaps.
//
// This test pins the physics-side invariant: a grounded body with no input
// must hold a byte-identical position across many frames. It PASSES — which
// is itself the evidence: the physics resting position is bit-stable, so the
// doorway flap is NOT a physics-rest jitter. See
// docs/research/2026-06-08-flap-physics-diagnosis-REFUTED-its-render-membership.md
// (the flap is render-side portal-flood membership instability at the grazing
// doorway portal under a sweeping camera eye). Kept as a regression guard.
[Fact]
public void Update_AtRestNoInput_RenderPositionBitStableAcrossManyFrames()
{
var engine = MakeFlatEngine();
var controller = new PlayerMovementController(engine);
var rest = new Vector3(96f, 96f, 50f);
controller.SetPosition(rest, 0x0001);
// Settle one frame so the resolver establishes its rest state, then
// capture the baseline the body must hold.
var settled = controller.Update(1f / 60f, new MovementInput());
Vector3 baselineRender = settled.RenderPosition;
Vector3 baselinePhysics = settled.Position;
// Hold still for ~10 s of 60 Hz frames (crosses MinQuantum every ~2
// frames, so the 30 Hz physics tick fires throughout — same cadence as
// live). Any deviation, even one ULP, is the flap's root cause.
float maxRenderDev = 0f;
float maxPhysicsDev = 0f;
for (int i = 0; i < 600; i++)
{
var r = controller.Update(1f / 60f, new MovementInput());
maxRenderDev = MathF.Max(maxRenderDev, (r.RenderPosition - baselineRender).Length());
maxPhysicsDev = MathF.Max(maxPhysicsDev, (r.Position - baselinePhysics).Length());
}
Assert.True(
maxRenderDev == 0f && maxPhysicsDev == 0f,
$"resting body drifted: render={maxRenderDev * 1e6f:F3} µm, " +
$"physics={maxPhysicsDev * 1e6f:F3} µm; expected byte-identical rest");
}
// After walking then releasing input, the body must SETTLE to a
// byte-identical resting position — not keep blipping a residual velocity.
// This models the live flap: the player walks to the cottage doorway and
// stops, and the eye then carries a ~1-ULP jitter that flips portal-flood
// membership. Flat-terrain variant: if even this drifts, the residual-after-
// motion path is the root and it is not indoor-specific.
[Fact]
public void Update_WalkThenStop_SettlesToBitStableRest()
{
var engine = MakeFlatEngine();
var controller = new PlayerMovementController(engine);
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
controller.Yaw = 0f;
// Walk forward ~0.5 s, then release.
for (int i = 0; i < 30; i++)
controller.Update(1f / 60f, new MovementInput(Forward: true));
// Let velocity decay / state settle.
for (int i = 0; i < 30; i++)
controller.Update(1f / 60f, new MovementInput());
var settled = controller.Update(1f / 60f, new MovementInput());
Vector3 basePos = settled.Position;
Vector3 baseRender = settled.RenderPosition;
float maxPos = 0f, maxRender = 0f;
for (int i = 0; i < 600; i++)
{
var r = controller.Update(1f / 60f, new MovementInput());
maxPos = MathF.Max(maxPos, (r.Position - basePos).Length());
maxRender = MathF.Max(maxRender, (r.RenderPosition - baseRender).Length());
}
Assert.True(maxPos == 0f && maxRender == 0f,
$"post-walk rest drifted: pos={maxPos * 1e6f:F3} µm, render={maxRender * 1e6f:F3} µm");
}
[Fact]
public void Update_ForwardInput_MovesInFacingDirection()
{
var engine = MakeFlatEngine();
var controller = new PlayerMovementController(engine);
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
controller.Yaw = 0f; // facing +X
// L.5 physics-tick gate (235de33, 2026-04-30): Update() integrates
// only one MinQuantum (~0.033s) per MaxQuantum (~0.1s) tick, matching
// retail's 30Hz physics. A single Update(1.0f) only advances one
// MaxQuantum step (~0.312m at walk speed 3.12 m/s). Drive the
// controller one MaxQuantum at a time for ~1s to accumulate real
// forward motion (8 × 0.1s = 0.8s × 3.12 m/s ≈ 2.5m).
var input = new MovementInput { Forward = true };
MovementResult result = default;
int ticks = (int)MathF.Ceiling(1.0f / PhysicsBody.MaxQuantum) + 1; // ~11 ticks
for (int i = 0; i < ticks; i++)
result = controller.Update(PhysicsBody.MaxQuantum, input);
// Should have moved >2 units in +X (walk speed over ~1s).
Assert.True(result.Position.X > 96f + 2f, $"X={result.Position.X} should have moved forward");
}
[Fact]
public void Update_AttachedAnimationWithZeroRootDelta_DoesNotGlideOnForwardEdge()
{
var controller = new PlayerMovementController(MakeFlatEngine());
var start = new Vector3(96f, 96f, 50f);
controller.SetPosition(start, 0x0001);
controller.Yaw = 0f;
controller.AttachAnimationRootMotionSource((_, _) => { });
MovementResult result = controller.Update(
ObjectTick,
new MovementInput(Forward: true));
Assert.Equal(start, result.Position);
Assert.Equal(0f, controller.BodyVelocity.X);
Assert.Equal(0f, controller.BodyVelocity.Y);
}
[Fact]
public void Update_AttachedAnimationRootDelta_DrivesGroundedBodyAtObjectScale()
{
var controller = new PlayerMovementController(MakeFlatEngine());
var start = new Vector3(96f, 96f, 50f);
controller.SetPosition(start, 0x0001);
controller.Yaw = 0f;
controller.ObjectScale = 2f;
controller.AttachAnimationRootMotionSource((_, frame) =>
frame.Origin = new Vector3(0f, 0.1f, 0f));
MovementResult result = controller.Update(
ObjectTick,
new MovementInput(Forward: true));
// Local +Y is forward. Yaw 0 maps it to world +X; m_scale doubles
// the animation-authored 0.1 m displacement to 0.2 m.
Assert.Equal(start.X + 0.2f, result.Position.X, precision: 3);
Assert.Equal(start.Y, result.Position.Y, precision: 3);
}
[Fact]
public void Update_SubQuantumFrames_AdvanceAnimationOnceAtObjectThreshold()
{
var controller = new PlayerMovementController(MakeFlatEngine());
var start = new Vector3(96f, 96f, 50f);
controller.SetPosition(start, 0x0001);
controller.Yaw = 0f;
int advances = 0;
controller.AttachAnimationRootMotionSource((_, frame) =>
{
advances++;
frame.Origin = new Vector3(0f, 0.1f, 0f);
});
MovementResult first = controller.Update(
ObjectTick * 0.5f,
new MovementInput(Forward: true));
MovementResult second = controller.Update(
ObjectTick * 0.5f,
new MovementInput(Forward: true));
Assert.Equal(start, first.Position);
Assert.Equal(start.X + 0.1f, second.Position.X, precision: 3);
Assert.Equal(start.Y, second.Position.Y, precision: 3);
Assert.Equal(1, advances);
}
[Fact]
public void Update_AttachedAnimationFrame_ComposesTranslationBeforeTurn()
{
var controller = new PlayerMovementController(MakeFlatEngine());
var start = new Vector3(96f, 96f, 50f);
controller.SetPosition(start, 0x0001);
controller.Yaw = 0f; // body local +Y faces world +X
controller.AttachAnimationRootMotionSource((_, frame) =>
{
frame.Origin = new Vector3(0f, 0.1f, 0f);
frame.Orientation = Quaternion.CreateFromAxisAngle(
Vector3.UnitZ,
MathF.PI / 2f);
});
MovementResult result = controller.Update(
ObjectTick,
new MovementInput());
// Retail Frame::combine transforms the delta origin by the OLD body
// orientation, then composes the delta orientation. The body therefore
// moves east before finishing the quarter-turn to north.
Assert.Equal(start.X + 0.1f, result.Position.X, precision: 3);
Assert.Equal(start.Y, result.Position.Y, precision: 3);
Assert.Equal(MathF.PI / 2f, controller.Yaw, precision: 3);
}
[Fact]
public void Update_AttachedAnimationFrame_PreservesCompleteNonCommutingOrientation()
{
var controller = new PlayerMovementController(MakeFlatEngine());
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
Quaternion initial = Quaternion.CreateFromAxisAngle(Vector3.UnitX, 1.1f);
Quaternion delta = Quaternion.CreateFromAxisAngle(Vector3.UnitY, -0.9f);
controller.SetBodyOrientation(initial);
controller.AttachAnimationRootMotionSource((_, frame) =>
frame.Orientation = delta);
controller.Update(ObjectTick, new MovementInput());
Quaternion expected = Quaternion.Normalize(initial * delta);
float alignment = MathF.Abs(Quaternion.Dot(
expected,
Quaternion.Normalize(controller.BodyOrientation)));
Assert.InRange(alignment, 0.99999f, 1.00001f);
Assert.True(MathF.Abs(Quaternion.Dot(
Quaternion.Normalize(delta * initial),
Quaternion.Normalize(controller.BodyOrientation))) < 0.999f);
}
[Fact]
public void Update_MultiQuantumAnimationFrames_ComposeInTemporalOrder()
{
var controller = new PlayerMovementController(MakeFlatEngine());
var start = new Vector3(96f, 96f, 50f);
controller.SetPosition(start, 0x0001);
controller.Yaw = 0f;
int sample = 0;
controller.AttachAnimationRootMotionSource((_, frame) =>
{
if (sample++ == 0)
{
frame.Orientation = Quaternion.CreateFromAxisAngle(
Vector3.UnitZ,
MathF.PI / 2f);
}
else
{
frame.Origin = new Vector3(0f, 0.1f, 0f);
}
});
MovementResult result = controller.Update(
PhysicsBody.MaxQuantum * 2f,
new MovementInput());
// The second local-forward displacement occurs after the first
// quarter-turn, so it moves north. Adding Origins as bare vectors
// would incorrectly move east.
Assert.Equal(start.X, result.Position.X, precision: 3);
Assert.Equal(start.Y + 0.1f, result.Position.Y, precision: 3);
Assert.Equal(MathF.PI / 2f, controller.Yaw, precision: 3);
}
[Fact]
public void Update_ExactMinQuantum_RetainsTimeWithoutAdvancingObject()
{
var controller = new PlayerMovementController(MakeFlatEngine());
var start = new Vector3(96f, 96f, 50f);
controller.SetPosition(start, 0x0001);
int advances = 0;
controller.AttachAnimationRootMotionSource((_, frame) =>
{
advances++;
frame.Origin = new Vector3(0f, 1f, 0f);
});
MovementResult atThreshold = controller.Update(
PhysicsBody.MinQuantum,
new MovementInput());
Assert.Equal(start, atThreshold.Position);
Assert.Equal(0, advances);
controller.Update(0.001f, new MovementInput());
Assert.Equal(1, advances);
}
[Fact]
public void Update_LargeFrame_MatchesSeparateRetailObjectQuanta()
{
var combined = new PlayerMovementController(MakeFlatEngine());
var split = new PlayerMovementController(MakeFlatEngine());
var start = new Vector3(96f, 96f, 50f);
combined.SetPosition(start, 0x0001);
split.SetPosition(start, 0x0001);
int combinedHooks = 0;
int splitHooks = 0;
static void Advance(float dt, AcDream.Core.Physics.Motion.MotionDeltaFrame frame)
{
frame.Origin = new Vector3(0f, dt * 2f, 0f);
frame.Orientation = Quaternion.CreateFromAxisAngle(
Vector3.UnitZ,
dt * 0.7f);
}
combined.AttachAnimationRootMotionSource(Advance, () => combinedHooks++);
split.AttachAnimationRootMotionSource(Advance, () => splitHooks++);
combined.Update(PhysicsBody.MaxQuantum * 2f, new MovementInput());
split.Update(PhysicsBody.MaxQuantum, new MovementInput());
split.Update(PhysicsBody.MaxQuantum, new MovementInput());
Assert.Equal(split.Position.X, combined.Position.X, precision: 5);
Assert.Equal(split.Position.Y, combined.Position.Y, precision: 5);
Assert.Equal(split.Position.Z, combined.Position.Z, precision: 5);
float alignment = MathF.Abs(Quaternion.Dot(
Quaternion.Normalize(split.BodyOrientation),
Quaternion.Normalize(combined.BodyOrientation)));
Assert.InRange(alignment, 0.99999f, 1.00001f);
Assert.Equal(2, combinedHooks);
Assert.Equal(2, splitHooks);
}
[Fact]
public void TickHidden_DoesNotAdvancePartArrayButStillProcessesHooks()
{
var controller = new PlayerMovementController(MakeFlatEngine());
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
int advances = 0;
int hookPasses = 0;
controller.AttachAnimationRootMotionSource(
(_, frame) =>
{
advances++;
frame.Origin = new Vector3(0f, 1f, 0f);
},
() => hookPasses++);
controller.TickHidden(ObjectTick);
Assert.Equal(0, advances);
Assert.Equal(1, hookPasses);
}
[Fact]
public void InvalidElapsed_VisibleFrameIsPurePresentationRead()
{
var controller = new PlayerMovementController(MakeFlatEngine());
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
float initialTime = controller.SimTimeSeconds;
float initialYaw = controller.Yaw;
Vector3 initialPosition = controller.Position;
RawMotionState initialMotion = controller.Motion.RawState;
var hostileInput = new MovementInput(
Forward: true,
TurnLeft: true,
Jump: true,
Run: true);
foreach (float elapsed in new[]
{
float.NaN,
float.PositiveInfinity,
float.NegativeInfinity,
-0.1f,
0f,
})
{
MovementResult result = controller.Update(elapsed, hostileInput);
Assert.False(result.ShouldSendMovementEvent);
}
Assert.Equal(initialTime, controller.SimTimeSeconds);
Assert.Equal(initialYaw, controller.Yaw);
Assert.Equal(initialPosition, controller.Position);
Assert.Equal(initialMotion, controller.Motion.RawState);
controller.Update(ObjectTick, new MovementInput());
controller.Update(ObjectTick, new MovementInput());
Assert.True(controller.AdvancedObjectQuantumLastTick);
controller.Update(float.NaN, hostileInput);
Assert.False(controller.AdvancedObjectQuantumLastTick);
}
[Fact]
public void InvalidElapsed_HiddenFrameDoesNotAdvanceClockOrManagerTail()
{
var controller = new PlayerMovementController(MakeFlatEngine());
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
float initialTime = controller.SimTimeSeconds;
int targetPasses = 0;
controller.TickHidden(float.NaN, () => targetPasses++);
controller.TickHidden(float.PositiveInfinity, () => targetPasses++);
controller.TickHidden(-0.1f, () => targetPasses++);
Assert.Equal(initialTime, controller.SimTimeSeconds);
Assert.Equal(0, targetPasses);
Assert.False(controller.AdvancedObjectQuantumLastTick);
controller.TickHidden(ObjectTick);
controller.TickHidden(ObjectTick);
Assert.True(controller.AdvancedObjectQuantumLastTick);
controller.TickHidden(float.PositiveInfinity);
Assert.False(controller.AdvancedObjectQuantumLastTick);
}
[Fact]
public void Update_AirbornePartArrayFrame_SuppressesOriginButPreservesOrientation()
{
var controller = new PlayerMovementController(MakeFlatEngine());
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
controller.Update(1f, new MovementInput(Jump: true));
Vector3 beforeRelease = controller.Position;
Quaternion beforeOrientation = controller.BodyOrientation;
Quaternion delta = Quaternion.CreateFromAxisAngle(Vector3.UnitY, 0.4f);
int advances = 0;
controller.AttachAnimationRootMotionSource((_, frame) =>
{
advances++;
frame.Origin = new Vector3(0f, 10f, 0f);
frame.Orientation = delta;
});
controller.Update(ObjectTick, new MovementInput(Jump: false));
Assert.True(controller.IsAirborne);
Assert.Equal(beforeRelease.X, controller.Position.X, precision: 5);
Assert.Equal(beforeRelease.Y, controller.Position.Y, precision: 5);
Quaternion expected = Quaternion.Normalize(beforeOrientation * delta);
float alignment = MathF.Abs(Quaternion.Dot(
expected,
Quaternion.Normalize(controller.BodyOrientation)));
Assert.InRange(alignment, 0.99999f, 1.00001f);
Assert.Equal(1, advances);
}
[Fact]
public void Update_AttachedAnimationTurn_IsNotAppliedByASecondYawIntegrator()
{
var controller = new PlayerMovementController(MakeFlatEngine());
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
controller.Yaw = 0f;
controller.AttachAnimationRootMotionSource((dt, frame) =>
{
frame.Orientation = Quaternion.CreateFromAxisAngle(
Vector3.UnitZ,
-(MathF.PI / 2f) * dt);
});
controller.Update(
ObjectTick,
new MovementInput(TurnRight: true));
Assert.Equal(
-(MathF.PI / 2f) * ObjectTick,
controller.Yaw,
precision: 4);
}
[Fact]
public void Update_SubQuantumFrame_InterpolatesRenderPositionWithoutAdvancingPhysicsPosition()
{
var engine = MakeFlatEngine();
var controller = new PlayerMovementController(engine);
var start = new Vector3(96f, 96f, 50f);
controller.SetPosition(start, 0x0001);
controller.Yaw = 0f;
var firstTick = controller.Update(ObjectTick, new MovementInput(Forward: true));
Assert.True(firstTick.Position.X > start.X, "Physics tick should advance the authoritative body position");
Assert.Equal(start.X, firstTick.RenderPosition.X, precision: 4);
var halfFrame = controller.Update(PhysicsBody.MinQuantum * 0.5f, new MovementInput(Forward: true));
Assert.Equal(firstTick.Position.X, halfFrame.Position.X, precision: 4);
Assert.True(halfFrame.RenderPosition.X > start.X, "Render position should move between physics ticks");
Assert.True(halfFrame.RenderPosition.X < firstTick.Position.X,
$"Render X={halfFrame.RenderPosition.X} should stay between {start.X} and {firstTick.Position.X}");
float expectedMidpoint = start.X + ((firstTick.Position.X - start.X) * 0.5f);
Assert.Equal(expectedMidpoint, halfFrame.RenderPosition.X, precision: 3);
}
[Fact]
public void SetPosition_ResnapsRenderInterpolationEndpoints()
{
var engine = MakeFlatEngine();
var controller = new PlayerMovementController(engine);
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
controller.Yaw = 0f;
controller.Update(ObjectTick, new MovementInput(Forward: true));
controller.Update(PhysicsBody.MinQuantum * 0.5f, new MovementInput(Forward: true));
var snapped = new Vector3(120f, 80f, 50f);
controller.SetPosition(snapped, 0x0001);
var result = controller.Update(PhysicsBody.MinQuantum * 0.5f, new MovementInput());
Assert.Equal(snapped, result.Position);
Assert.Equal(snapped, result.RenderPosition);
}
[Fact]
public void BlipPosition_ResnapsPoseWithoutStoppingActiveMotion()
{
var controller = new PlayerMovementController(MakeFlatEngine());
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
controller.Yaw = 0f;
controller.Update(ObjectTick, new MovementInput(Forward: true));
Vector3 velocity = controller.BodyVelocity;
Assert.True(velocity.LengthSquared() > 0f);
var corrected = new Vector3(100f, 98f, 50f);
controller.BlipPosition(corrected, 0x0001, corrected);
Assert.Equal(corrected, controller.Position);
Assert.Equal(corrected, controller.RenderPosition);
Assert.Equal(velocity, controller.BodyVelocity);
}
[Fact]
public void BlipPosition_PublishesCanonicalOutdoorCellAndLocalFrame()
{
var controller = new PlayerMovementController(MakeFlatEngine());
var world = new Vector3(150f, 193f, 50f);
var wireLocal = new Vector3(150f, 193f, 50f);
controller.BlipPosition(world, 0xA9B30038u, wireLocal);
Assert.Equal(0xA9B40031u, controller.CellId);
Assert.Equal(controller.CellId, controller.CellPosition.ObjCellId);
Assert.Equal(new Vector3(150f, 1f, 50f), controller.CellPosition.Frame.Origin);
Assert.Equal(world, controller.Position);
}
[Fact]
public void TeleportPosition_PublishesCanonicalOutdoorCellAndLocalFrame()
{
var controller = new PlayerMovementController(MakeFlatEngine());
var world = new Vector3(12f, 12f, 50f);
var wireLocal = new Vector3(12f, 12f, 50f);
controller.SetPosition(world, 0xA9B40031u, wireLocal);
Assert.Equal(0xA9B40001u, controller.CellId);
Assert.Equal(controller.CellId, controller.CellPosition.ObjCellId);
Assert.Equal(wireLocal, controller.CellPosition.Frame.Origin);
}
[Fact]
public void Update_HugeQuantumDiscard_ResnapsRenderInterpolationEndpoints()
{
var engine = MakeFlatEngine();
var controller = new PlayerMovementController(engine);
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
controller.Yaw = 0f;
int animationAdvances = 0;
int hookPasses = 0;
controller.AttachAnimationRootMotionSource(
(_, frame) =>
{
animationAdvances++;
frame.Origin = new Vector3(0f, 0.1f, 0f);
},
() => hookPasses++);
var moved = controller.Update(ObjectTick, new MovementInput(Forward: true));
int advancesBeforeDiscard = animationAdvances;
int hooksBeforeDiscard = hookPasses;
var stale = controller.Update(PhysicsBody.HugeQuantum + 0.1f, new MovementInput(Forward: true));
Assert.Equal(moved.Position.X, stale.Position.X, precision: 4);
Assert.Equal(stale.Position, stale.RenderPosition);
Assert.Equal(advancesBeforeDiscard, animationAdvances);
Assert.Equal(hooksBeforeDiscard, hookPasses);
}
[Fact]
public void Update_LeftoverAboveMinQuantum_ClampsRenderAlphaToCurrentPhysicsPosition()
{
var engine = MakeFlatEngine();
var controller = new PlayerMovementController(engine);
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
controller.Yaw = 0f;
var result = controller.Update(
PhysicsBody.MaxQuantum + PhysicsBody.MinQuantum,
new MovementInput(Forward: true));
// Tolerance, not decimal `precision:` — the AP-7 friction port (P2)
// shifts the velocity-fallback trajectory by micrometers, and
// Math.Round-based precision comparison fails when two essentially
// equal values straddle a 5e-5 rounding boundary (observed: X
// 96.3427505 vs 96.3427429 — a 7.6 µm gap rounding to 96.3428 vs
// 96.3427). The clamp contract is "render == physics for
// presentation"; 1 mm is far below visibility and boundary-immune.
Assert.Equal(result.Position.X, result.RenderPosition.X, tolerance: 1e-3f);
Assert.Equal(result.Position.Y, result.RenderPosition.Y, tolerance: 1e-3f);
Assert.Equal(result.Position.Z, result.RenderPosition.Z, tolerance: 1e-3f);
}
[Fact]
public void Update_RunForward_MoveFasterThanWalk()
{
var engine = MakeFlatEngine();
var controller = new PlayerMovementController(engine);
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
controller.Yaw = 0f;
var walkInput = new MovementInput { Forward = true };
var walkResult = controller.Update(1.0f, walkInput);
float walkDist = walkResult.Position.X - 96f;
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
var runInput = new MovementInput { Forward = true, Run = true };
var runResult = controller.Update(1.0f, runInput);
float runDist = runResult.Position.X - 96f;
Assert.True(runDist > walkDist, $"Run ({runDist}) should be faster than walk ({walkDist})");
}
[Fact]
public void Update_TurnInput_ChangesYaw()
{
var engine = MakeFlatEngine();
var controller = new PlayerMovementController(engine);
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
float initialYaw = controller.Yaw;
var input = new MovementInput { TurnRight = true };
controller.Update(0.5f, input);
Assert.NotEqual(initialYaw, controller.Yaw);
}
[Fact]
public void MotionStateChanged_WhenStartingToWalk()
{
var engine = MakeFlatEngine();
var controller = new PlayerMovementController(engine);
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
// First frame: idle (no input).
controller.Update(0.016f, new MovementInput());
// Second frame: start walking.
var input = new MovementInput { Forward = true };
var result = controller.Update(0.016f, input);
Assert.True(result.MotionStateChanged);
}
[Fact]
public void Update_JumpOnFlatTerrain_BecomesAirborne()
{
var engine = MakeFlatEngine();
var controller = new PlayerMovementController(engine);
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
// Charged jump: hold for a full charge (1s dt), then release to fire.
// A full charge gives enough Vz that the player clears the 0.05-unit
// ground-snap threshold within the same integration frame.
controller.Update(1.0f, new MovementInput(Jump: true)); // full charge
controller.Update(0.016f, new MovementInput(Jump: false)); // release → jump fires
Assert.True(controller.IsAirborne);
Assert.True(controller.VerticalVelocity > 0f);
}
[Fact]
public void PositionEventGateRequiresContactAndWalkable()
{
var engine = MakeFlatEngine();
var controller = new PlayerMovementController(engine);
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
Assert.True(controller.CanSendPositionEvent);
controller.Update(1.0f, new MovementInput(Jump: true));
controller.Update(0.016f, new MovementInput(Jump: false));
Assert.True(controller.IsAirborne);
Assert.False(controller.CanSendPositionEvent);
}
[Fact]
public void JumpChargeSnapshot_TracksHeldChargeAndResetsOnRelease()
{
var engine = MakeFlatEngine();
var controller = new PlayerMovementController(engine);
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
Assert.Equal(default, controller.JumpCharge);
controller.Update(0.25f, new MovementInput(Jump: true));
Assert.True(controller.JumpCharge.IsCharging);
Assert.Equal(0.25f, controller.JumpCharge.Power, precision: 3);
controller.Update(0.016f, new MovementInput(Jump: false));
Assert.False(controller.JumpCharge.IsCharging);
Assert.Equal(0f, controller.JumpCharge.Power);
}
[Fact]
public void Update_AirborneFrames_ZRiseThenFalls()
{
var engine = MakeFlatEngine();
var controller = new PlayerMovementController(engine);
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
// Charged jump: hold for a full charge, then release.
controller.Update(1.0f, new MovementInput(Jump: true)); // full charge
controller.Update(0.016f, new MovementInput(Jump: false)); // release → jump fires
float z1 = controller.Position.Z;
// A few frames of rising
controller.Update(0.1f, new MovementInput());
float z2 = controller.Position.Z;
Assert.True(z2 > z1, "Should be rising");
// Many frames — should come back down.
// DefaultJumpVz = 10 m/s → full flight time ≈ 2.04s, so run 50 × 50ms = 2.5s
// to ensure the player has definitely landed.
for (int i = 0; i < 50; i++)
controller.Update(0.05f, new MovementInput());
Assert.False(controller.IsAirborne, "Should have landed");
Assert.Equal(50f, controller.Position.Z, precision: 1);
}
[Fact]
public void Update_WalkOffLedge_BecomesFalling()
{
// Build terrain with a sharp cliff: grid x<5 = Z50, grid x>=5 = Z20.
// heights[x*9+y] is indexed x-major; heightTable[i]=i*1f so
// byte value == Z value directly.
var heights = new byte[81];
for (int x = 0; x < 9; x++)
for (int y = 0; y < 9; y++)
heights[x * 9 + y] = (byte)(x < 5 ? 50 : 20);
var heightTable = new float[256];
for (int i = 0; i < 256; i++) heightTable[i] = i * 1f;
var engine = new PhysicsEngine();
var terrain = new TerrainSurface(heights, heightTable);
engine.AddLandblock(0xA9B4FFFFu, terrain, Array.Empty<CellSurface>(),
Array.Empty<PortalPlane>(), worldOffsetX: 0f, worldOffsetY: 0f);
// Position the player just before the cliff edge (localX=118 ≈ grid x=4.92).
// At this point terrain Z is ~51.7 (bilinear interpolation near the high side).
// One step at walk speed will cross into the low region where terrain drops
// ~28 units — more than StepUpHeight=5, triggering the ledge-fall.
var controller = new PlayerMovementController(engine);
controller.SetPosition(new Vector3(118f, 96f, 50f), 0x0001);
controller.Yaw = 0f; // facing +X
// Single step — should trigger airborne state because terrain drops sharply.
controller.Update(0.05f, new MovementInput(Forward: true));
Assert.True(controller.IsAirborne, "Player should be airborne after stepping off the cliff");
// Simulate enough frames to fall and land on the Z=20 floor.
for (int i = 0; i < 60; i++)
controller.Update(0.05f, new MovementInput(Forward: true));
Assert.False(controller.IsAirborne, "Player should have landed");
Assert.Equal(20f, controller.Position.Z, precision: 1);
}
// ── Campaign P Slice P1 (2026-07-30): burden/stamina push ──────────────
[Fact]
public void SetCharacterBurden_PropagatesToTheWeenieAndGatesCanJump()
{
var controller = new PlayerMovementController(MakeFlatEngine());
IWeenieObject weenie = controller.Motion.WeenieObj!;
Assert.True(weenie.CanJump(1.0f));
controller.SetCharacterBurden(2.5f);
Assert.False(weenie.CanJump(1.0f));
controller.SetCharacterBurden(0.5f);
Assert.True(weenie.CanJump(1.0f));
}
[Fact]
public void SetCharacterStamina_ZeroesEffectiveSkillOnTheWeenie()
{
var controller = new PlayerMovementController(MakeFlatEngine());
controller.SetCharacterSkills(runSkill: 200, jumpSkill: 100);
IWeenieObject weenie = controller.Motion.WeenieObj!;
Assert.True(weenie.InqRunRate(out float baseline));
controller.SetCharacterStamina(0);
Assert.True(weenie.InqRunRate(out float exhausted));
Assert.True(exhausted < baseline);
controller.SetCharacterStamina(-1);
Assert.True(weenie.InqRunRate(out float restored));
Assert.Equal(baseline, restored, precision: 4);
}
// ── Campaign P Slice P5 (2026-07-30): ConstraintManager leash arming (#167) ──
//
// docs/research/2026-07-30-constraint-leash-constants.md. The player's
// PositionManager is handed in by EnterPlayerModeNow in production; these
// tests wire an EntityPhysicsHost the same way so SetPosition/BlipPosition's
// new ConstrainTo/UnConstrain calls have somewhere real to land.
private static (PlayerMovementController Controller, EntityPhysicsHost Host)
MakeControllerWithHost()
{
var controller = new PlayerMovementController(MakeFlatEngine());
var hosts = new Dictionary<uint, IPhysicsObjHost>();
const uint selfGuid = 0x5000000Au;
var host = new EntityPhysicsHost(
selfGuid,
getPosition: () => controller.CellPosition,
getVelocity: () => controller.BodyVelocity,
getRadius: () => 0.5f,
inContact: () => controller.BodyInContact,
minterpMaxSpeed: () => controller.Motion.GetMaxSpeed(),
curTime: () => 0.0,
physicsTimerTime: () => 0.0,
getObjectA: id => hosts.TryGetValue(id, out var h) ? h : null,
handleUpdateTarget: _ => { },
interruptCurrentMovement: () => { });
hosts[selfGuid] = host;
controller.PositionManager = host.PositionManager;
return (controller, host);
}
[Fact]
public void SetPosition_Teleport_ArmsConstraintAnchoredToReceivedPositionOutdoor()
{
var (controller, _) = MakeControllerWithHost();
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001); // low16 < 0x0100 -> outdoor
ConstraintManager cm = controller.PositionManager!.Constraint!;
Assert.True(cm.IsConstrained);
Assert.Equal(10.0f, cm.ConstraintDistanceStart); // ACE-inversion pin: NOT 5
Assert.Equal(50.0f, cm.ConstraintDistanceMax);
Assert.Equal(controller.Position, cm.ConstraintPos.Frame.Origin);
// Anchored to self (the just-received position) -> zero offset at arm time.
Assert.Equal(0f, cm.ConstraintPosOffset, 3);
// R3-W6's teleport idle (StopCompletelyAtPhysicsObjectBoundary) still
// zeroes velocity — the new UnConstrain/ConstrainTo calls compose with
// it rather than replacing it.
Assert.Equal(Vector3.Zero, controller.BodyVelocity);
}
[Fact]
public void SetPosition_Teleport_IndoorCellUsesTheTighterBand()
{
var (controller, _) = MakeControllerWithHost();
controller.SetPosition(
new Vector3(10f, 10f, 5f),
0x01000105u); // low16 = 0x0105 >= 0x0100 -> indoor (verbatim, no outdoor canonicalization)
ConstraintManager cm = controller.PositionManager!.Constraint!;
Assert.Equal(5.0f, cm.ConstraintDistanceStart);
Assert.Equal(20.0f, cm.ConstraintDistanceMax);
}
[Fact]
public void SetPosition_Teleport_TearsDownAndRearmsAPreviouslyFullyConstrainedLeash()
{
var (controller, _) = MakeControllerWithHost();
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
ConstraintManager cm = controller.PositionManager!.Constraint!;
// Synthetically over-strain the leash with a tight band (production
// arming uses the real 10/50 m band; a tight synthetic band here just
// makes "was fully constrained before this teleport" cheap to reach).
cm.ConstrainTo(controller.CellPosition, startDistance: 0.1f, maxDistance: 0.2f);
cm.AdjustOffset(new MotionDeltaFrame { Origin = new Vector3(5f, 0f, 0f) }, quantum: 0.1);
Assert.True(controller.PositionManager.IsFullyConstrained());
controller.SetPosition(new Vector3(150f, 150f, 50f), 0x0001);
// retail teleport_hook's UnConstrain, followed by the fresh re-arm at
// the new position, clears the stale over-strained state.
Assert.False(controller.PositionManager.IsFullyConstrained());
Assert.Equal(0f, cm.ConstraintPosOffset, 3);
}
[Fact]
public void BlipPosition_ArmsConstraintButDoesNotTearDownOrZeroVelocity()
{
var (controller, _) = MakeControllerWithHost();
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
controller.Update(ObjectTick, new MovementInput(Forward: true));
Vector3 velocityBeforeBlip = controller.BodyVelocity;
Assert.NotEqual(Vector3.Zero, velocityBeforeBlip); // sanity: actually moving
controller.BlipPosition(
new Vector3(150f, 150f, 50f),
0x0001,
new Vector3(150f, 150f, 50f));
// BlipPlayer (retail 0x00453940) survives motion/velocity/stick — the
// leash is no different: ConstrainTo runs with NO preceding UnConstrain
// and no StopCompletely.
Assert.Equal(velocityBeforeBlip, controller.BodyVelocity);
ConstraintManager cm = controller.PositionManager!.Constraint!;
Assert.True(cm.IsConstrained);
Assert.Equal(controller.Position, cm.ConstraintPos.Frame.Origin);
Assert.Equal(0f, cm.ConstraintPosOffset, 3);
}
[Fact]
public void Update_ConstraintArmedInBand_TapersALargeRootMotionOffsetOnTheSecondTick()
{
var (controller, _) = MakeControllerWithHost();
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
ConstraintManager cm = controller.PositionManager!.Constraint!;
cm.ConstrainTo(controller.CellPosition, startDistance: 1f, maxDistance: 10f);
controller.AttachAnimationRootMotionSource((dt, frame) =>
{
frame.Origin = new Vector3(5f, 0f, 0f); // wildly large per-tick root motion, on purpose
});
// Tick 1: the gate reads the OFFSET RECORDED BEFORE this tick (0, from
// ConstrainTo, below start) -- passes through close to the raw 5 m.
Vector3 beforeTick1 = controller.Position;
controller.Update(ObjectTick, new MovementInput());
float displacement1 = (controller.Position - beforeTick1).Length();
Assert.True(displacement1 > 4.0f,
$"first tick should pass through near-unscaled, got {displacement1}");
// Tick 2: ConstraintPosOffset is now ~5 (recorded from tick 1), inside
// the (1,10) band -- the linear taper now visibly brakes the SAME 5 m
// raw input.
Vector3 beforeTick2 = controller.Position;
controller.Update(ObjectTick, new MovementInput());
float displacement2 = (controller.Position - beforeTick2).Length();
Assert.True(displacement2 > 0f);
Assert.True(displacement2 < 4.0f,
$"second tick should be tapered well below the raw 5 m input, got {displacement2}");
}
[Fact]
public void Update_ConstraintOverstrained_PushesIsFullyConstrainedOntoBodyAndBlocksJump()
{
var (controller, _) = MakeControllerWithHost();
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
ConstraintManager cm = controller.PositionManager!.Constraint!;
cm.ConstrainTo(controller.CellPosition, startDistance: 1f, maxDistance: 2f);
controller.AttachAnimationRootMotionSource((dt, frame) =>
{
frame.Origin = new Vector3(10f, 0f, 0f); // one huge tick, past max
});
controller.Update(ObjectTick, new MovementInput());
Assert.True(controller.PositionManager!.IsFullyConstrained());
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 PublicationLifecycleRequiresExplicitActivationAfterOwnershipCommit()
{
var candidate = PlayerMovementController.CreatePublicationCandidate(
new PhysicsEngine(),
PlayerMovementConstructionOptions.Fallback);
candidate.LocalEntityId = 0x70004001u;
candidate.StepUpHeight = 0.4f;
candidate.StepDownHeight = 0.4f;
candidate.ObjectScale = 1f;
candidate.PreparePositionForCommit(
new Vector3(1f, 2f, 3f),
0xA9B40021u,
new Vector3(1f, 2f, 3f));
candidate.SetBodyOrientation(Quaternion.Identity);
candidate.ApplyPhysicsState(PhysicsStateFlags.Gravity);
PhysicsBody body = candidate.PhysicsBody;
body.InWorld = false;
body.TransientState &= ~TransientStateFlags.Active;
Assert.False(body.InWorld);
candidate.SealPublicationCandidate();
Assert.True(candidate.IsSealedPublicationCandidate);
Assert.Throws<InvalidOperationException>(() => candidate.Update(
1f / 60f,
default));
Assert.Throws<InvalidOperationException>(() => candidate.TickHidden(
1f / 60f));
Assert.Throws<InvalidOperationException>(() => candidate.SetPosition(
Vector3.One,
0xA9B40021u,
Vector3.One));
Assert.Throws<InvalidOperationException>(() => candidate.BlipPosition(
Vector3.One,
0xA9B40021u,
Vector3.One));
Assert.Throws<InvalidOperationException>(() =>
candidate.CaptureMovementResult(mouseLookEvent: false));
Assert.Throws<InvalidOperationException>(() =>
candidate.NoteMovementSent(1f));
Assert.Throws<InvalidOperationException>(candidate.CommitPreparedPosition);
Assert.Throws<InvalidOperationException>(() =>
candidate.ApplyPhysicsState(PhysicsStateFlags.Frozen));
Assert.Throws<InvalidOperationException>(() => candidate.LocalEntityId = 2u);
Assert.Throws<InvalidOperationException>(() => _ = candidate.Movement);
var canonicalClock = new RetailObjectQuantumClock();
candidate.CommitRuntimeOwnership(canonicalClock);
Assert.Throws<InvalidOperationException>(() => candidate.Update(
1f / 60f,
default));
Assert.Throws<InvalidOperationException>(() =>
candidate.CaptureMovementResult(mouseLookEvent: false));
Assert.Throws<InvalidOperationException>(() => _ = candidate.PhysicsBody);
candidate.ActivateRuntimePublication();
Assert.Same(body, candidate.PhysicsBody);
_ = candidate.CaptureMovementResult(mouseLookEvent: false);
_ = candidate.Update(0f, default);
candidate.ApplyPhysicsState(PhysicsStateFlags.Gravity);
candidate.RetireRuntimePublication();
Assert.Throws<InvalidOperationException>(() => candidate.Update(
1f / 60f,
default));
}
[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. The
// landing-bounce rework (2026-07-30,
// docs/research/2026-07-30-landing-bounce-family.md) restores retail
// handle_all_collisions' reflect (v += -(v·n)(elasticity+1)·n,
// elasticity 0.05), so the first post-landing ticks are a micro-hop
// CHAIN: each ground contact reverses 5% of the impact's normal
// component and keeps the tangential -- the residual speed survives,
// and calc_friction engages once a hop's contact dot falls under the
// 0.25 AP-7 gate. "Survives, then decays" therefore measures a few
// ticks after touchdown, not the very first one (friction cannot run
// during the reflected rise -- that IS retail's landing bounce).
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})");
// Let the 5%-elasticity hop chain settle (v_z decays geometrically;
// a handful of ObjectTicks covers several hops), then require decay.
for (int i = 0; i < 12; i++)
controller.Update(ObjectTick, new MovementInput());
float horizSpeedSettled =
new Vector2(controller.BodyVelocity.X, controller.BodyVelocity.Y).Length();
Assert.True(horizSpeedSettled < horizSpeedAtLaunch,
$"Expected friction to decay the residual speed once the bounce chain settles; " +
$"got {horizSpeedSettled} vs launch {horizSpeedAtLaunch}");
}
}