acdream/tests/AcDream.Runtime.Tests/Gameplay/PlayerMovementControllerTests.cs
Erik 77c8296e3f feat(chat): Campaign CH slice CH2 — retail SpewBox interface text
Retail routes on-screen refusals ("You can't jump while in the air",
"You are too encumbered to carry that!") through a SEPARATE transient
screen surface (gmSpewBoxUI, ClientSystem::AddTextToScroll @0x00563C50)
that never touches the chat scroll — type 0x1A is exactly the bit every
ChatInterface window's default filter excludes
(ChatInterface::ChatInterface @0x004F4550). acdream had no such split:
every WeenieError rendered in chat at a single stand-in LogTextType
0x00 (CH1-era approximation, register AP-176), and locally-detected
jump refusals were silently discarded.

This slice ports the full mechanism per
docs/research/2026-08-09-chat-retail-interface-text.md:

CORE (AcDream.Core/Chat):
- WeenieErrorMessages.Resolve now returns (text, RetailLogTextType) from
  a 338-row transcription of ClientCommunicationSystem::HandleFailureEvent
  @0x00571990 (Appendix A's 339 cases minus one, 0x4F8, deliberately
  excluded — its case body is a tangled decompiler artifact, not
  resolvable with confidence). Spot-checked ~20 rows directly against
  the raw decomp (case 0x2b/0x36/0x3a/0x4e/0x4ec/0x4f3/0x4f4 and the
  jump family), beyond the ~10 the brief asked for, because the first
  pass surfaced two transcription classes the research doc's markdown
  silently ate: (1) 7 ids marked "shared string global" resolved by
  reading the case bodies directly (0x24/0x48/0x49 reuse the jump-
  refusal globals; 0x4DE/0x4DF/0x55A/0x55E are pure param passthrough);
  (2) 19 "arg3 + literal" CONCATENATION ids whose leading space (and
  therefore their %s marker) the markdown table's cell-trimming ate —
  fixed by re-reading each case body, several requiring a SECOND
  non-truncated data_XXXXXXXX dump elsewhere in the same oracle file to
  recover text the ~33-char inline preview cut off. One retail typo is
  preserved verbatim: 0x4F4's second placeholder is literal "$s", not
  "%s" — only the first substitutes.
- ClientTextRefusals: the 11 process-lifetime string globals, all
  byte-recovered from the PDB-paired C:\Users\erikn\Downloads\acclient.exe
  (MATCH verified via check_exe_pdb.py) via raw UTF-16LE prefix search —
  5 were truncated in the research doc's own transcription and all 5
  turned out to end "...combat mode"/"...this position", not the
  shorter "...combat" a truncated read would suggest.
- SpewBoxState: the gmSpewBoxUI pending/visible queue port (insert-at-0,
  dedupe-against-index-0-only, MaxConcurrentItems overflow, per-entry
  expiry, one-frame enqueue/drain decoupling). Placed in Core (not
  Runtime as the brief's default) because AcDream.UI.Abstractions
  references Core but not Runtime, and SpewBoxVM needs to wrap it
  directly — the same constraint ChatVM already satisfies against
  ChatLog.
- Folded the 4-entry WeenieErrorText.cs into the full table; deleted it.

RUNTIME (AcDream.Runtime):
- RuntimeCommunicationState.AddText(text, type, windowId): the
  AddTextToScroll chokepoint. type == ClientLocal -> SpewBox only, never
  chat; everything else -> the existing transcript, tagged with type.
- GameEventWiring gains an `onInterfaceText` delegate hole (Core.Net
  cannot reference Runtime, so this follows the file's own established
  pattern for every other Runtime-owned sink). Rewires 0x028A/0x028B/
  UseDone through the full table + router; fixes 0x02EB
  CommunicationTransientString's routing type from a CH1-era 0x00
  guess to retail's hardcoded ClientLocal (Handle_Communication__
  TransientString @0x0057D460).
- LiveSessionEventRouter's 0xF7E0 ServerMessage handler now routes
  through AddText with the wire chatType verbatim instead of always
  writing ChatLog directly.
- PlayerMovementController gains OnInterfaceText, applied by
  RuntimeLocalPlayerMovementState to every controller it installs.
  Reports ChargeJump/jump refusals exactly as ClientCombatSystem::
  CommenceJump @0x0056AF90 / DoJump @0x0056B110 do — confirmed via
  their compiled dispatch that ONLY 0x24/0x48/0x49 produce text;
  0x47 (GeneralMovementFailure, fully-constrained/no-stamina) and any
  other code are retail-SILENT (DoJump's jump table has exactly 4 real
  targets), which contradicts this task's brief ("0x47 -> the
  constrained/stamina row per §4.2") — the brief's reading of §4.2
  described what jump_is_allowed COMPUTES, not what CommenceJump/DoJump
  DISPLAY for it. Implemented the decomp-verified silent behavior.

APP (AcDream.App / AcDream.UI.Abstractions):
- The 5 composition sites that already used RetailLogTextType.ClientLocal
  now call Communication.AddText instead of Chat.OnSystemMessage
  directly, so they reach the SpewBox instead of the transcript.
- SpewBoxVM (UI.Abstractions) + SpewBoxController (App), modeled
  directly on PortalWaitNoticeController. Position/font/colour/
  MaxConcurrentItems are placeholders: SpewBoxLayoutDumpDiagnostic
  exhaustively swept the installed client_portal.dat's entire LayoutDesc
  id range (0x21000000-0x21000075, 101/118 ids populated, sanity-checked
  against 3 known ids) and found ZERO elements of class 0x10000016 —
  gmSpewBoxUI is mounted from C++ code, not any authored LayoutDesc, so
  the dump cannot recover these values.

REGISTER: AP-176 retired (its WeenieError half is now the full table
port); its OnCombatLine half was never in this slice's scope and is
split out to AP-179 so that divergence keeps a row. AP-177 (invented
line lifetime) and AP-178 (invented position/font/colour/max-items)
filed for the presentation placeholders above. AP-175 (PopUpString ->
chat instead of modal) is untouched, not duplicated.

Suite: 11,890 passed / 4 skipped / 0 failed (was 11,835/4/0; +55 net
new tests, 0 regressions).

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

1329 lines
59 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 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}");
}
}