acdream/tests/AcDream.Core.Tests/Physics/Motion/MovementManagerTests.cs
Erik 7b7ffcd278 fix(gameplay): reconcile wield ownership and target facing
Preserve PlayerDescription inventory/equipment ownership across authoritative manifest replacement, make weapon switching and combat/UI consumers read the same canonical object state, and carry the complete outbound player position frame across landblocks.

Route target-facing and mouse-look through the shared MovementManager and MotionInterpreter completion owner. Match retail input aggregation, toggle ordering, turn/sidestep remapping, per-axis hold keys, and synchronous movement publication without render-only heading state.

Initialize the live streaming origin from the first accepted canonical player Position, defer other projections until that origin exists, and retain logical entity identity through hydration.

Advance the project ledger from completed M2 to active M3, synchronize CLAUDE.md/AGENTS.md and durable memory, and record the next cast-lifecycle, spellbook/enchantment, and two-client portal gates.

Co-Authored-By: Codex <noreply@openai.com>
2026-07-15 08:19:23 +02:00

379 lines
14 KiB
C#

using System.Numerics;
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
using Xunit;
namespace AcDream.Core.Tests.Physics.Motion;
/// <summary>
/// R5-V5 — <see cref="MovementManager"/> facade conformance (retail struct
/// acclient.h /* 3463 */; methods 0x00524000-0x00524790, decomp extract
/// <c>docs/research/2026-07-03-r5-managers/r5-movementmanager-decomp.md</c>).
/// The facade is pure relay/ownership — these tests pin the retail call
/// shapes: which child each method touches, the lazy MakeMoveToManager
/// create, the PerformMovement type dispatch, and null-tolerance before the
/// moveto manager exists. Behavior of the children themselves is covered by
/// the MotionInterpreter / MoveToManager suites (UNMODIFIED by R5-V5).
/// </summary>
public sealed class MovementManagerTests
{
/// <summary>Non-creature weenie: silences the MotionInterpreter's
/// HitGround/LeaveGround creature gates (retail IsCreature vtable
/// +0x2c) so a test can isolate the facade's MOVETO relay leg.</summary>
private sealed class NonCreatureWeenie : IWeenieObject
{
public bool InqJumpVelocity(float extent, out float vz) { vz = 0f; return false; }
public bool InqRunRate(out float rate) { rate = 1f; return false; }
public bool CanJump(float extent) => false;
bool IWeenieObject.IsCreature() => false;
}
/// <summary>Facade over the shared MoveToManagerHarness: the harness's
/// REAL MotionInterpreter is the minterp child; the harness's REAL
/// seam-scripted MoveToManager arrives via <see cref="MovementManager.MoveToFactory"/>
/// (the acdream stand-in for retail's physics_obj/weenie_obj
/// backpointers that MakeMoveToManager constructs from).</summary>
private static (MovementManager Mm, MoveToManagerHarness H, int[] FactoryCalls) MakeFacade()
{
var h = new MoveToManagerHarness();
var factoryCalls = new int[1];
var mm = new MovementManager(h.Interp)
{
MoveToFactory = () => { factoryCalls[0]++; return h.Manager; },
};
return (mm, h, factoryCalls);
}
// ── MakeMoveToManager — 0x00524000 ──────────────────────────────────────
[Fact]
public void MakeMoveToManager_CreatesViaFactory_ExactlyOnce()
{
var (mm, h, calls) = MakeFacade();
Assert.Null(mm.MoveTo);
mm.MakeMoveToManager();
Assert.Same(h.Manager, mm.MoveTo);
Assert.Equal(1, calls[0]);
// Retail: no-op if already present.
mm.MakeMoveToManager();
Assert.Same(h.Manager, mm.MoveTo);
Assert.Equal(1, calls[0]);
}
[Fact]
public void MakeMoveToManager_WithoutFactory_IsANoOp()
{
var mm = new MovementManager(new MotionInterpreter());
mm.MakeMoveToManager();
Assert.Null(mm.MoveTo);
}
// ── PerformMovement — 0x005240d0 (the type-1..9 two-way dispatch) ───────
[Fact]
public void PerformMovement_InterpTypes_RouteToMinterp_NotMoveTo()
{
var (mm, h, calls) = MakeFacade();
var result = mm.PerformMovement(new MovementStruct
{
Type = MovementType.InterpretedCommand,
Motion = MotionCommand.WalkForward,
Speed = 1f,
ModifyInterpretedState = true,
});
Assert.Equal(WeenieError.None, result);
Assert.Equal(MotionCommand.WalkForward, h.Interp.InterpretedState.ForwardCommand);
// Types 1-5 never touch the moveto side (jump table 0x0052415c).
Assert.Equal(0, calls[0]);
Assert.Null(mm.MoveTo);
}
[Fact]
public void PerformMovement_InterpTypes_ForwardOriginalParametersObject()
{
// CMotionInterp::PerformMovement @ 0x00528E80 forwards
// MovementStruct::params verbatim. This is observable at the player
// input boundary: rebuilding the block from MovementStruct's legacy
// scalar fields loses its speed, hold key, and control flags.
var (mm, h, calls) = MakeFacade();
int interrupts = 0;
h.Interp.InterruptCurrentMovement = () => interrupts++;
var parameters = new MovementParameters
{
Speed = 0.37f,
SetHoldKey = true,
HoldKeyToApply = HoldKey.Run,
CancelMoveTo = false,
ModifyRawState = true,
ModifyInterpretedState = true,
};
var result = mm.PerformMovement(new MovementStruct
{
Type = MovementType.RawCommand,
Motion = MotionCommand.TurnRight,
// Deliberately contradictory legacy fields: Params wins.
Speed = 9f,
ModifyRawState = false,
ModifyInterpretedState = false,
Params = parameters,
});
Assert.Equal(WeenieError.None, result);
Assert.Equal(0.37f, h.Interp.RawState.TurnSpeed);
Assert.Equal(HoldKey.Run, h.Interp.RawState.CurrentHoldKey);
Assert.Equal(0, interrupts);
Assert.Equal(0, calls[0]);
}
[Fact]
public void PerformMovement_MoveToTypes_LazyCreate_AndRouteToMoveTo()
{
var (mm, h, calls) = MakeFacade();
var result = mm.PerformMovement(new MovementStruct
{
Type = MovementType.MoveToPosition,
Pos = new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity),
Params = new MovementParameters(),
});
// Retail cases 5-8 (types 6-9): MakeMoveToManager first, delegate,
// and the MoveToManager path's return is NOT propagated (@0052414f
// `return 0`) — the facade reports None regardless.
Assert.Equal(WeenieError.None, result);
Assert.Equal(1, calls[0]);
Assert.Equal(MovementType.MoveToPosition, h.Manager.MovementTypeState);
}
[Fact]
public void PerformMovement_InvalidAndOutOfRangeTypes_Fail0x47()
{
var (mm, _, calls) = MakeFacade();
// Retail head: (type - 1) > 8 → 0x47. Type 0 underflows unsigned →
// always > 8; anything above 9 fails the same check.
Assert.Equal(WeenieError.GeneralMovementFailure,
mm.PerformMovement(new MovementStruct { Type = MovementType.Invalid }));
Assert.Equal(WeenieError.GeneralMovementFailure,
mm.PerformMovement(new MovementStruct { Type = (MovementType)10 }));
Assert.Equal(0, calls[0]);
}
[Fact]
public void PerformMovement_MoveToType_WithoutFactory_Fails0x47()
{
// acdream-only guard for the unreachable-in-production ordering
// (a type-6..9 event before the bind sites set MoveToFactory):
// retail would MoveToManager::Create here; without a factory the
// facade reports the same 0x47 the range check uses.
var mm = new MovementManager(new MotionInterpreter());
Assert.Equal(WeenieError.GeneralMovementFailure,
mm.PerformMovement(new MovementStruct { Type = MovementType.TurnToHeading }));
}
// ── UseTime — 0x005242f0 (moveto only; never lazy-creates) ──────────────
[Fact]
public void UseTime_BeforeMoveToExists_IsANoOp_AndDoesNotCreate()
{
var (mm, _, calls) = MakeFacade();
mm.UseTime();
Assert.Equal(0, calls[0]);
Assert.Null(mm.MoveTo);
}
[Fact]
public void UseTime_RelaysToMoveTo()
{
// The MoveToManagerUseTimeGateTests arrival shape, driven through
// the facade: grounded, facing the target, arrived — one UseTime
// completes the move.
var (mm, h, _) = MakeFacade();
h.ContactValue = true;
h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity);
h.Heading = 90f;
mm.PerformMovement(new MovementStruct
{
Type = MovementType.MoveToPosition,
Pos = new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity),
Params = new MovementParameters { DistanceToObject = 0.6f, UseSpheres = false },
});
h.DrainPendingMotions();
h.WorldPosition = new Position(1u, new Vector3(19.7f, 0f, 0f), Quaternion.Identity);
h.Advance(2.0);
mm.UseTime();
Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState);
}
// ── HitGround — 0x00524300 (minterp FIRST, then moveto) ────────────────
[Fact]
public void HitGround_RelaysToMinterp_AndToleratesNullMoveTo()
{
var (mm, h, _) = MakeFacade();
h.Body.State |= PhysicsStateFlags.Gravity; // CMotionInterp::HitGround gates on state & 0x400
bool minterpHit = false;
h.Interp.RemoveLinkAnimations = () => minterpHit = true;
mm.HitGround(); // MoveTo still null — retail's if-present guard
Assert.True(minterpHit);
}
[Fact]
public void HitGround_RelaysMinterpFirst_ThenMoveTo()
{
var (mm, h, _) = MakeFacade();
h.Body.State |= PhysicsStateFlags.Gravity;
h.ContactValue = true;
h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity);
h.Heading = 90f;
mm.PerformMovement(new MovementStruct
{
Type = MovementType.MoveToPosition,
Pos = new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity),
Params = new MovementParameters(),
});
h.DrainPendingMotions();
// Order pin: the FIRST RemoveLinkAnimations firing belongs to the
// minterp leg (CMotionInterp::HitGround invokes it before any
// dispatch), at which point the moveto leg's BeginNextNode
// re-dispatch has NOT happened yet — the pending queue is still
// drained. Later firings (the moveto dispatch's own TS-40
// detached-strip at the DoInterpretedMotion tail runs AFTER its
// enqueue) must not overwrite the recording — ??= keeps the first.
bool? queueEmptyAtMinterpLeg = null;
h.Interp.RemoveLinkAnimations =
() => queueEmptyAtMinterpLeg ??= !h.Interp.MotionsPending();
mm.HitGround();
Assert.True(queueEmptyAtMinterpLeg); // minterp leg ran first
Assert.True(h.Interp.MotionsPending()); // a re-dispatch landed after it
}
[Fact]
public void HitGround_ReachesMoveTo_WhenMinterpLegIsGated()
{
// Isolate the MOVETO leg: a non-creature weenie makes
// CMotionInterp::HitGround a retail no-op (IsCreature gate), so any
// re-dispatched pending motion can only come from
// MoveToManager::HitGround → BeginNextNode.
var (mm, h, _) = MakeFacade();
h.Interp.WeenieObj = new NonCreatureWeenie();
h.ContactValue = true;
h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity);
h.Heading = 90f;
mm.PerformMovement(new MovementStruct
{
Type = MovementType.MoveToPosition,
Pos = new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity),
Params = new MovementParameters(),
});
h.DrainPendingMotions();
mm.HitGround();
Assert.True(h.Interp.MotionsPending());
}
// ── HandleExitWorld — 0x00524350 (minterp ONLY) ─────────────────────────
[Fact]
public void HandleExitWorld_DrainsMinterp_AndDoesNotTouchMoveTo()
{
var (mm, h, _) = MakeFacade();
h.ContactValue = true;
h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity);
h.Heading = 90f;
mm.PerformMovement(new MovementStruct
{
Type = MovementType.MoveToPosition,
Pos = new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity),
Params = new MovementParameters(),
});
Assert.True(h.Interp.MotionsPending()); // the arm's dispatch is queued
mm.HandleExitWorld();
Assert.False(h.Interp.MotionsPending());
// Retail HandleExitWorld does NOT touch moveto_manager — the armed
// move survives (its teardown is CancelMoveTo / exit-world at the
// CPhysicsObj layer, not here).
Assert.Equal(MovementType.MoveToPosition, h.Manager.MovementTypeState);
}
// ── CancelMoveTo — 0x005241b0 / IsMovingTo — 0x00524260 ────────────────
[Fact]
public void CancelMoveTo_NullTolerant_AndRelaysToMoveTo()
{
var (mm, h, _) = MakeFacade();
mm.CancelMoveTo(WeenieError.ActionCancelled); // no moveto yet — no throw
h.ContactValue = true;
h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity);
mm.PerformMovement(new MovementStruct
{
Type = MovementType.MoveToPosition,
Pos = new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity),
Params = new MovementParameters(),
});
Assert.True(mm.IsMovingTo());
mm.CancelMoveTo(WeenieError.ActionCancelled);
Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState);
Assert.False(mm.IsMovingTo());
Assert.True(h.StopCompletelyCalls > 0);
}
[Fact]
public void IsMovingTo_FalseBeforeMoveToExists()
{
var (mm, _, _) = MakeFacade();
Assert.False(mm.IsMovingTo());
}
// ── HandleUpdateTarget — 0x00524790 (→ moveto) ──────────────────────────
[Fact]
public void HandleUpdateTarget_NullTolerant_AndFeedsMoveToDeferredStart()
{
var (mm, h, _) = MakeFacade();
var info = new TargetInfo
{
ObjectId = 0x5000AAAAu,
Status = TargetStatus.Ok,
TargetPosition = new Position(1u, new Vector3(10f, 0f, 0f), Quaternion.Identity),
InterpolatedPosition = new Position(1u, new Vector3(10f, 0f, 0f), Quaternion.Identity),
};
mm.HandleUpdateTarget(info); // no moveto yet — no throw
// The V2 "uninitialized type-6 stall": MoveToObject defers its node
// build to the FIRST HandleUpdateTarget delivery.
h.ContactValue = true;
h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity);
mm.PerformMovement(new MovementStruct
{
Type = MovementType.MoveToObject,
ObjectId = 0x5000AAAAu,
TopLevelId = 0x5000AAAAu,
Pos = new Position(1u, new Vector3(10f, 0f, 0f), Quaternion.Identity),
Params = new MovementParameters(),
});
Assert.False(h.Manager.Initialized);
mm.HandleUpdateTarget(info);
Assert.True(h.Manager.Initialized);
}
}