acdream/tests/AcDream.Runtime.Tests/Gameplay/PlayerMovementControllerTests.cs
Erik 47e40900f3 fix(chat): Campaign CH user-gate round 1 — jump-in-air edge, portal cue cadence, wrap/prefix/color fixes
The user tested Campaign CH's CODE-COMPLETE build live and reported ten
defects (docs/plans/2026-08-09-chat-parity-campaign.md, "User gate —
round 1"). Items A-G are fixed here; the remaining three (extra chat
windows on 1/2/3/4, resize working in only one corner, transparency/
artifacts) are out of scope for a fix and filed as slice CH6.

A. Jump-in-air refusal never fired live: the jump block only ever
   evaluated input.Jump inside the grounded-charge or already-charging
   branches. PlayerMovementController now detects the press RISING EDGE
   while airborne and reports WeenieError.NotGrounded once per press,
   leaving the grounded charge/fire path untouched.
B. ChatVM's invented "[System] " prefix is dropped — retail prints
   system text bare. [Popup] is unchanged (AP-175).
C. SpewBoxController's color is now the user-pinned exact value
   (1, 1, 0.247, 1), the same bright yellow as an incoming Tell.
   Register row AP-178 updated: color CLOSES, size/position/font stay
   open per the user's live report that they still differ.
D. Closes #329: PortalTunnelPresentation now emits the portal wait cue
   unconditionally on every rotation-segment boundary, matching
   gmSmartBoxUI::UseTime's decompiled else-arm exactly instead of gating
   on a 5-second hold local transits never reached. PortalWaitNotice
   Controller now renders it in the same pinned yellow as item C.
   Register row AP-150 retired.
E. Closes #362: new ClientCommandResponses.cs parses and renders the
   four previously-unhandled inbound GameEvents (ChannelIndex,
   ChannelList, AvailableHouses, AllegianceInfoResponse), each ported
   line-for-line from the named-retail decomp's inbound handlers.
   Register row TS-70 retired.
F. ChatWindowController.WrapText now splits on embedded '\n'/'\r\n'
   first, then word-wraps each segment independently — server text like
   /help's reply no longer collapses onto one line.
G. The chat input field's right edge no longer holds a fixed absolute
   pixel position across a window resize; Bind now upgrades it to
   retail edge-mode 1 (UiLayoutPolicy) or the AnchorEdges.Right stretch
   fallback so it tracks the window's client width instead of
   overflowing past a narrower resize.

Full Release suite: 12,247 passed / 4 skipped / 0 failed (baseline
12,221/4/0 + 26 new tests across items A, E, F, G).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 23:42:32 +02:00

1374 lines
61 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.Chat;
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.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
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.SeedPlacementForTest(rest, 0x0001, rest);
// 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.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
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.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
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.SeedPlacementForTest(start, 0x0001, start);
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.SeedPlacementForTest(start, 0x0001, start);
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.SeedPlacementForTest(start, 0x0001, start);
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.SeedPlacementForTest(start, 0x0001, start);
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.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
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.SeedPlacementForTest(start, 0x0001, start);
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.SeedPlacementForTest(start, 0x0001, start);
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.SeedPlacementForTest(start, 0x0001, start);
split.SeedPlacementForTest(start, 0x0001, start);
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.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
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.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
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.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
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.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
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.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
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.SeedPlacementForTest(start, 0x0001, start);
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.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
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.SeedPlacementForTest(snapped, 0x0001, snapped);
var result = controller.Update(PhysicsBody.MinQuantum * 0.5f, new MovementInput());
Assert.Equal(snapped, result.Position);
Assert.Equal(snapped, result.RenderPosition);
}
[Fact]
public void CommitCanonicalForcePositionFrame_ReconcilesPoseWithoutStoppingActiveMotion()
{
var controller = new PlayerMovementController(MakeFlatEngine());
controller.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
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);
// C4 route 2: simulates Runtime's canonical SetPosition commit
// (RuntimeSetPositionState.CommitCanonical:4459-4462), which snaps
// the SAME PhysicsBody directly BEFORE the controller reconciles
// its render-lerp/cell state. The deleted BlipPosition used to do
// both steps itself; CommitCanonicalForcePositionFrame only does the
// second.
controller.PhysicsBody.SnapToCell(0x0001, corrected, corrected);
controller.CommitCanonicalForcePositionFrame();
Assert.Equal(corrected, controller.Position);
Assert.Equal(corrected, controller.RenderPosition);
Assert.Equal(velocity, controller.BodyVelocity);
}
[Fact]
public void CommitCanonicalForcePositionFrame_PublishesCanonicalOutdoorCellAndLocalFrame()
{
var controller = new PlayerMovementController(MakeFlatEngine());
var world = new Vector3(150f, 193f, 50f);
var wireLocal = new Vector3(150f, 193f, 50f);
controller.PhysicsBody.SnapToCell(0xA9B30038u, world, wireLocal);
controller.CommitCanonicalForcePositionFrame();
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.SeedPlacementForTest(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.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
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.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
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.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
controller.Yaw = 0f;
var walkInput = new MovementInput { Forward = true };
var walkResult = controller.Update(1.0f, walkInput);
float walkDist = walkResult.Position.X - 96f;
controller.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
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.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
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.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
// 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.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
// 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.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
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.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
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.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
// 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.SeedPlacementForTest(new Vector3(118f, 96f, 50f), 0x0001, new Vector3(118f, 96f, 50f));
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 CH slice CH2: local jump refusals reach the SpewBox ───────
[Fact]
public void ChargeJump_RefusedByOverBurden_ReportsCantJumpLoadedDown()
{
// CACQualities::CanJump refuses past 200% load (PlayerWeenie.CanJump,
// CanJumpLoadThreshold = 2.0f) — a real, production-reachable refusal,
// not a fake. Retail: ClientCombatSystem::CommenceJump @0x0056AF90's
// eax_3 == 0x49 branch -> cant_jump_load.
var engine = MakeFlatEngine();
var controller = new PlayerMovementController(engine);
controller.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
controller.SetCharacterBurden(2.5f);
var reported = new List<(string Text, AcDream.Core.Chat.RetailLogTextType Type)>();
controller.OnInterfaceText = (text, type) => reported.Add((text, type));
controller.Update(0.016f, new MovementInput(Jump: true));
var report = Assert.Single(reported);
Assert.Equal(ClientTextRefusals.CantJumpLoad, report.Text);
Assert.Equal(AcDream.Core.Chat.RetailLogTextType.ClientLocal, report.Type);
Assert.False(controller.IsAirborne, "a refused charge must not launch the player");
}
[Fact]
public void ChargeJump_Succeeds_ReportsNothing()
{
var engine = MakeFlatEngine();
var controller = new PlayerMovementController(engine);
controller.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
var reported = new List<string>();
controller.OnInterfaceText = (text, _) => reported.Add(text);
controller.Update(1.0f, new MovementInput(Jump: true)); // full charge
controller.Update(0.016f, new MovementInput(Jump: false)); // release -> jump fires
Assert.Empty(reported);
Assert.True(controller.IsAirborne);
}
[Fact]
public void ChargeJump_RefusalWithNoObserverWired_DoesNotThrow()
{
// OnInterfaceText defaults to null (no production wiring in a bare
// test controller) — the refusal path must be a safe no-op, not a
// NullReferenceException.
var engine = MakeFlatEngine();
var controller = new PlayerMovementController(engine);
controller.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
controller.SetCharacterBurden(2.5f);
var exception = Record.Exception(() =>
controller.Update(0.016f, new MovementInput(Jump: true)));
Assert.Null(exception);
}
// ── Campaign CH user-gate round 1 (item A): airborne jump refusal ──────
[Fact]
public void JumpPress_RisingEdgeWhileAirborne_ReportsCantJumpInAir_HeldOnlyOnce_NoneAfterLanding()
{
var engine = MakeFlatEngine();
var controller = new PlayerMovementController(engine);
controller.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
// Launch into the air with an ordinary charged jump — the fix must
// not touch this grounded charge/fire path at all.
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);
controller.Update(0.05f, new MovementInput()); // clear the ground before pressing again
var reported = new List<string>();
controller.OnInterfaceText = (text, _) => reported.Add(text);
// Rising edge while airborne: exactly one "You can't jump while in
// the air" report.
controller.Update(0.016f, new MovementInput(Jump: true));
var report = Assert.Single(reported);
Assert.Equal(ClientTextRefusals.CantJumpInAir, report);
// Holding the key across multiple further updates raises no
// additional reports.
controller.Update(0.016f, new MovementInput(Jump: true));
controller.Update(0.016f, new MovementInput(Jump: true));
controller.Update(0.016f, new MovementInput(Jump: true));
Assert.Single(reported);
// Release, then land.
controller.Update(0.016f, new MovementInput(Jump: false));
for (int i = 0; i < 60 && controller.IsAirborne; i++)
controller.Update(0.05f, new MovementInput());
Assert.False(controller.IsAirborne, "should have landed");
// Landing then pressing again while grounded raises none (the
// grounded charge succeeds normally for an unburdened character).
reported.Clear();
controller.Update(0.016f, new MovementInput(Jump: true));
Assert.Empty(reported);
}
// ── 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.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f)); // 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.SeedPlacementForTest(
new Vector3(10f, 10f, 5f),
0x01000105u, new Vector3(10f, 10f, 5f)); // 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.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
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.SeedPlacementForTest(new Vector3(150f, 150f, 50f), 0x0001, new Vector3(150f, 150f, 50f));
// 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 CommitCanonicalForcePositionFrame_DoesNotRearmConstraintLeashOrTouchVelocity()
{
var (controller, _) = MakeControllerWithHost();
var initial = new Vector3(96f, 96f, 50f);
controller.SeedPlacementForTest(initial, 0x0001, initial);
controller.Update(ObjectTick, new MovementInput(Forward: true));
Vector3 velocityBeforeCommit = controller.BodyVelocity;
Assert.NotEqual(Vector3.Zero, velocityBeforeCommit); // sanity: actually moving
ConstraintManager cm = controller.PositionManager!.Constraint!;
Vector3 leashAnchorBeforeCommit = cm.ConstraintPos.Frame.Origin;
var corrected = new Vector3(150f, 150f, 50f);
// Simulates Runtime's canonical SetPosition commit writing the SAME
// PhysicsBody directly, exactly as CommitCanonicalForcePositionFrame
// expects to find it.
controller.PhysicsBody.SnapToCell(0x0001, corrected, corrected);
controller.CommitCanonicalForcePositionFrame();
// C4 route 2 (2026-08-03): retail's FORCE_POSITION branch of
// SmartBox::HandleReceivedPosition (0x00453FD0) returns at
// 0x0045409D, before every CPhysicsObj::ConstrainTo call
// (0x00454272/0x0045418A/0x004541EC) — the branch BlipPlayer runs
// on is not one of them. Unlike the deleted BlipPosition (which
// re-armed the leash to the corrected position — an unbacked
// deviation), this method must leave the leash anchored exactly
// where it already was. Motion/velocity are untouched either way.
Assert.Equal(velocityBeforeCommit, controller.BodyVelocity);
Assert.Equal(leashAnchorBeforeCommit, cm.ConstraintPos.Frame.Origin);
Assert.NotEqual(corrected, cm.ConstraintPos.Frame.Origin);
}
[Fact]
public void Update_ConstraintArmedInBand_TapersALargeRootMotionOffsetOnTheSecondTick()
{
var (controller, _) = MakeControllerWithHost();
controller.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
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.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
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.SeedPlacementForTest(start, 0x0001, start);
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.SeedPlacementForTest(
Vector3.One,
0xA9B40021u,
Vector3.One));
Assert.Throws<InvalidOperationException>(() =>
candidate.CommitCanonicalForcePositionFrame());
Assert.Throws<InvalidOperationException>(() =>
candidate.CaptureMovementResult(mouseLookEvent: false));
Assert.Throws<InvalidOperationException>(() =>
candidate.NoteMovementSent(1f));
// C5a (2026-08-05): CommitPreparedPosition is deleted (zero production
// callers); ArmConstraintLeashAtCommittedPlacement is its production
// replacement and carries the SAME EnsurePublishedForRuntimeOperation
// guard at its own entry, so this re-points rather than drops.
Assert.Throws<InvalidOperationException>(
candidate.ArmConstraintLeashAtCommittedPlacement);
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.BeginDormantSetPositionGroundPhase();
Assert.Throws<InvalidOperationException>(
candidate.CommitRuntimeActivationFrame);
Assert.Throws<InvalidOperationException>(
candidate.ActivateRuntimePublication);
candidate.EndDormantSetPositionGroundPhase();
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.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
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}");
}
}