feat(R4-V4): REMOTE cutover - per-remote MoveToManager; RemoteMoveToDriver + PlanMoveToStart DELETED (closes M1-remote, M4/M5/M6/M8-remote; retires AD-8, AD-9, AP-8, AP-9)
Every remote's server-directed movement now runs the verbatim retail MoveToManager. EnsureRemoteMotionBindings constructs it per remote with the full V2 seam set (position/heading/velocity providers, the P4 TargetTracker adapter via setTarget/clearTarget storing the tracked guid on RemoteMotion) and binds InterruptCurrentMovement -> CancelMoveTo(0x36) - the retail interrupt chain (TS-36 remote side). UM routing is retail's unpack_movement dispatch (0x00524440): the head-interrupt + unstick fire for EVERY movement type, then mt 6/7 build MovementParameters.FromWire(raw bitfield - now surfaced by the parser) + a MovementStruct (mt 6 resolves the target guid against the entity table, degrading to MoveToPosition at the wire origin per the plan's 2f; my_run_rate written from MoveToRunRate per @300603) -> MoveTo.PerformMovement; mt 8/9 likewise via FromWireTurnTo; ONLY mt 0 flows through the interpreted funnel (the PlanMoveToStart seed is DELETED - retail never routes MoveTo types through the interpreted state copy). Per tick: the P4 tracker feeds HandleUpdateTarget(Ok/ExitWorld) from the live entity table, then UseTime runs the manager's steering/ arrival/fail handlers, then apply_current_movement recomputes velocity - the same shape ordinary locomotion uses. The legacy driver branches, the stale-destination timer, and the ClampApproachVelocity band-aid are gone; arrival now uses retail cylinder distance (watch melee-range stop distance in the visual pass). DELETED: RemoteMoveToDriver.cs + its tests (OriginToWorld relocated verbatim to MoveToMath; the B.6 auto-walk's two surviving constants inlined into PlayerMovementController, dying with it in V5); ServerControlledLocomotion.PlanMoveToStart + tests (PlanFromVelocity survives - new AP-80 row); the RemoteMotion MoveTo capture fields. Registers: AD-8/AD-9/AP-8/AP-9 retired; AP-79 (TargetTracker adapter, retire R5) + AP-80 added. Full suite green: 3,948 passed. Live smoke launched - NPC chase/wander behavior pending user verification (recorded in the session handoff). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
a144e87318
commit
7016b26ce7
10 changed files with 248 additions and 883 deletions
|
|
@ -1,330 +0,0 @@
|
|||
using System;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Physics;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// Phase L.1c (2026-04-28). Covers <see cref="RemoteMoveToDriver"/> — the
|
||||
/// per-tick steering port of retail
|
||||
/// <c>MoveToManager::HandleMoveToPosition</c> for server-controlled remote
|
||||
/// creatures.
|
||||
/// </summary>
|
||||
public class RemoteMoveToDriverTests
|
||||
{
|
||||
private const float Epsilon = 1e-3f;
|
||||
|
||||
private static float Yaw(Quaternion q)
|
||||
{
|
||||
var fwd = Vector3.Transform(new Vector3(0, 1, 0), q);
|
||||
return MathF.Atan2(-fwd.X, fwd.Y);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Drive_AlreadyAtTarget_ReportsArrived()
|
||||
{
|
||||
var bodyPos = new Vector3(10f, 20f, 0f);
|
||||
var bodyRot = Quaternion.Identity;
|
||||
var dest = new Vector3(10f, 20.3f, 0f);
|
||||
|
||||
var result = RemoteMoveToDriver.Drive(
|
||||
bodyPos, bodyRot, dest,
|
||||
minDistance: 0.5f, distanceToObject: 0.6f,
|
||||
dt: 0.016f, moveTowards: true,
|
||||
out var newOrient);
|
||||
|
||||
Assert.Equal(RemoteMoveToDriver.DriveResult.Arrived, result);
|
||||
Assert.Equal(bodyRot, newOrient); // orientation untouched
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Drive_AceMeleePacket_UsesDistanceToObjectAsArrival()
|
||||
{
|
||||
// ACE chase packet: MinDistance=0, DistanceToObject=0.6 (melee).
|
||||
// Body at 0.5m from target should ARRIVE — not keep oscillating
|
||||
// around the target the way it did pre-fix when only MinDistance
|
||||
// was the gate. This is the "monster keeps running in different
|
||||
// directions when it should be attacking" regression fix.
|
||||
var bodyPos = new Vector3(0f, 0f, 0f);
|
||||
var bodyRot = Quaternion.Identity;
|
||||
var dest = new Vector3(0f, 0.5f, 0f);
|
||||
|
||||
var result = RemoteMoveToDriver.Drive(
|
||||
bodyPos, bodyRot, dest,
|
||||
minDistance: 0f, distanceToObject: 0.6f,
|
||||
dt: 0.016f, moveTowards: true,
|
||||
out _);
|
||||
|
||||
Assert.Equal(RemoteMoveToDriver.DriveResult.Arrived, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Drive_FleeArrival_UsesMinDistance()
|
||||
{
|
||||
// Flee branch (moveTowards=false): arrival when dist >= MinDistance.
|
||||
// Retail / ACE both use MinDistance for the flee-arrival threshold.
|
||||
var bodyPos = new Vector3(0f, 0f, 0f);
|
||||
var bodyRot = Quaternion.Identity;
|
||||
var dest = new Vector3(0f, 6f, 0f);
|
||||
|
||||
var result = RemoteMoveToDriver.Drive(
|
||||
bodyPos, bodyRot, dest,
|
||||
minDistance: 5.0f, distanceToObject: 0.6f,
|
||||
dt: 0.016f, moveTowards: false,
|
||||
out _);
|
||||
|
||||
Assert.Equal(RemoteMoveToDriver.DriveResult.Arrived, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Drive_ChaseDoesNotArriveAtMinDistanceFloor()
|
||||
{
|
||||
// Regression: my earlier max(MinDistance, DistanceToObject) port
|
||||
// would have arrived here because dist (1.5) <= MinDistance (2.0).
|
||||
// Retail uses DistanceToObject for chase arrival, so a chase at
|
||||
// dist=1.5 with DistanceToObject=0.6 should still STEER, not arrive.
|
||||
var bodyPos = new Vector3(0f, 0f, 0f);
|
||||
var bodyRot = Quaternion.Identity;
|
||||
var dest = new Vector3(0f, 1.5f, 0f);
|
||||
|
||||
var result = RemoteMoveToDriver.Drive(
|
||||
bodyPos, bodyRot, dest,
|
||||
minDistance: 2.0f, distanceToObject: 0.6f,
|
||||
dt: 0.016f, moveTowards: true,
|
||||
out _);
|
||||
|
||||
Assert.Equal(RemoteMoveToDriver.DriveResult.Steering, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Drive_ChasingButNotInRange_ReportsSteering()
|
||||
{
|
||||
var bodyPos = new Vector3(0f, 0f, 0f);
|
||||
var bodyRot = Quaternion.Identity; // facing +Y
|
||||
var dest = new Vector3(0f, 50f, 0f); // straight ahead
|
||||
|
||||
var result = RemoteMoveToDriver.Drive(
|
||||
bodyPos, bodyRot, dest,
|
||||
minDistance: 0f, distanceToObject: 0f,
|
||||
dt: 0.016f, moveTowards: true,
|
||||
out var newOrient);
|
||||
|
||||
Assert.Equal(RemoteMoveToDriver.DriveResult.Steering, result);
|
||||
// Already facing target → snap branch keeps yaw at 0.
|
||||
Assert.InRange(Yaw(newOrient), -Epsilon, Epsilon);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Drive_TargetSlightlyOffAxis_SnapsWithinTolerance()
|
||||
{
|
||||
// Body facing +Y; target at (1, 10, 0) — that's a small angle
|
||||
// (about 5.7°), well within the 20° snap tolerance.
|
||||
var bodyPos = Vector3.Zero;
|
||||
var bodyRot = Quaternion.Identity;
|
||||
var dest = new Vector3(1f, 10f, 0f);
|
||||
|
||||
var result = RemoteMoveToDriver.Drive(
|
||||
bodyPos, bodyRot, dest,
|
||||
minDistance: 0f, distanceToObject: 0f,
|
||||
dt: 0.016f, moveTowards: true,
|
||||
out var newOrient);
|
||||
|
||||
Assert.Equal(RemoteMoveToDriver.DriveResult.Steering, result);
|
||||
// Snap should land us pointing at (1, 10): yaw = atan2(-1, 10) ≈ -0.0997 rad.
|
||||
float expectedYaw = MathF.Atan2(-1f, 10f);
|
||||
Assert.InRange(Yaw(newOrient), expectedYaw - Epsilon, expectedYaw + Epsilon);
|
||||
|
||||
// Verify orientation actually transforms +Y onto the (1,10) line.
|
||||
var worldFwd = Vector3.Transform(new Vector3(0, 1, 0), newOrient);
|
||||
Assert.InRange(worldFwd.X / worldFwd.Y, 0.1f - 1e-3f, 0.1f + 1e-3f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Drive_TargetBeyondTolerance_RotatesByLimitedStep()
|
||||
{
|
||||
// Body facing +Y; target at (-10, 0) — that's 90° to the left
|
||||
// (well beyond the 20° snap tolerance), so we turn by at most
|
||||
// TurnRateRadPerSec * dt this tick rather than snapping.
|
||||
var bodyPos = Vector3.Zero;
|
||||
var bodyRot = Quaternion.Identity; // yaw = 0
|
||||
var dest = new Vector3(-10f, 0f, 0f); // yaw = +π/2 (left)
|
||||
const float dt = 0.1f;
|
||||
|
||||
var result = RemoteMoveToDriver.Drive(
|
||||
bodyPos, bodyRot, dest,
|
||||
minDistance: 0f, distanceToObject: 0f,
|
||||
dt: dt, moveTowards: true,
|
||||
out var newOrient);
|
||||
|
||||
Assert.Equal(RemoteMoveToDriver.DriveResult.Steering, result);
|
||||
float expectedStep = RemoteMoveToDriver.TurnRateRadPerSec * dt;
|
||||
// We should turn LEFT (positive yaw) toward the target.
|
||||
Assert.InRange(Yaw(newOrient), expectedStep - Epsilon, expectedStep + Epsilon);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Drive_TargetBehind_TurnsRightOrLeftViaShortestPath()
|
||||
{
|
||||
// Body facing +Y; target directly behind at (0, -10, 0).
|
||||
// |delta| = π, equally close either way; the implementation
|
||||
// picks one (sign depends on float wobble) — just assert
|
||||
// we made progress (yaw changed by exactly TurnRate * dt).
|
||||
var bodyPos = Vector3.Zero;
|
||||
var bodyRot = Quaternion.Identity;
|
||||
var dest = new Vector3(0f, -10f, 0f);
|
||||
const float dt = 0.1f;
|
||||
|
||||
var result = RemoteMoveToDriver.Drive(
|
||||
bodyPos, bodyRot, dest,
|
||||
minDistance: 0f, distanceToObject: 0f,
|
||||
dt: dt, moveTowards: true,
|
||||
out var newOrient);
|
||||
|
||||
Assert.Equal(RemoteMoveToDriver.DriveResult.Steering, result);
|
||||
float expectedStep = RemoteMoveToDriver.TurnRateRadPerSec * dt;
|
||||
Assert.InRange(MathF.Abs(Yaw(newOrient)), expectedStep - Epsilon, expectedStep + Epsilon);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Drive_PreservesOrientationAtArrival()
|
||||
{
|
||||
var bodyPos = new Vector3(5f, 5f, 0f);
|
||||
var bodyRot = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 1.234f);
|
||||
var dest = new Vector3(5.01f, 5.01f, 0f);
|
||||
|
||||
var result = RemoteMoveToDriver.Drive(
|
||||
bodyPos, bodyRot, dest,
|
||||
minDistance: 0.5f, distanceToObject: 0.6f,
|
||||
dt: 0.016f, moveTowards: true,
|
||||
out var newOrient);
|
||||
|
||||
Assert.Equal(RemoteMoveToDriver.DriveResult.Arrived, result);
|
||||
// Caller would zero velocity; orientation should be untouched
|
||||
// so the body settles facing whatever direction it was already.
|
||||
Assert.Equal(bodyRot, newOrient);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClampApproachVelocity_NoOverShoot_LandsExactlyAtThreshold()
|
||||
{
|
||||
// Body 1 m from destination, running at 4 m/s, dt = 0.1 s.
|
||||
// Naive advance = 0.4 m → would end at 0.6 m from dest, exactly
|
||||
// on the threshold. With threshold=0.6 and remaining=0.4, the
|
||||
// clamp should let the full velocity through (advance == remaining).
|
||||
var bodyPos = new Vector3(0f, 0f, 0f);
|
||||
var dest = new Vector3(0f, 1f, 0f);
|
||||
var vel = new Vector3(0f, 4f, 0f);
|
||||
|
||||
var clamped = RemoteMoveToDriver.ClampApproachVelocity(
|
||||
bodyPos, vel, dest, arrivalThreshold: 0.6f, dt: 0.1f, moveTowards: true);
|
||||
|
||||
// Within float-precision: 4 m/s × 0.1 s = 0.4 m, exactly the
|
||||
// remaining distance. The clamp may apply a 0.99999×-style
|
||||
// tiny scale due to FP rounding — accept anything ≥ 99.9% of
|
||||
// the input as "no meaningful overshoot prevention applied."
|
||||
Assert.InRange(clamped.Y, 4f * 0.999f, 4f);
|
||||
Assert.Equal(0f, clamped.X);
|
||||
Assert.Equal(0f, clamped.Z);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClampApproachVelocity_WouldOverShoot_ScalesDownToExactLanding()
|
||||
{
|
||||
// Body 1 m from destination, running at 4 m/s, dt = 0.2 s.
|
||||
// Naive advance = 0.8 m → would overshoot 0.6 m threshold by 0.4 m.
|
||||
// remaining = 0.4 m, advance = 0.8 m → scale = 0.5.
|
||||
// Velocity should be halved → 2 m/s.
|
||||
var bodyPos = new Vector3(0f, 0f, 0f);
|
||||
var dest = new Vector3(0f, 1f, 0f);
|
||||
var vel = new Vector3(0f, 4f, 0f);
|
||||
|
||||
var clamped = RemoteMoveToDriver.ClampApproachVelocity(
|
||||
bodyPos, vel, dest, arrivalThreshold: 0.6f, dt: 0.2f, moveTowards: true);
|
||||
|
||||
Assert.InRange(clamped.Y, 2f - Epsilon, 2f + Epsilon);
|
||||
Assert.Equal(0f, clamped.X);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClampApproachVelocity_AlreadyAtThreshold_ZeroesHorizontal()
|
||||
{
|
||||
// Body exactly 0.6 m from dest with threshold 0.6 → remaining ≈ 0.
|
||||
// Any horizontal velocity would overshoot; clamp must zero it.
|
||||
var bodyPos = new Vector3(0f, 0f, 0f);
|
||||
var dest = new Vector3(0f, 0.6f, 0f);
|
||||
var vel = new Vector3(0f, 4f, 0.5f); // some Z to confirm Z is preserved
|
||||
|
||||
var clamped = RemoteMoveToDriver.ClampApproachVelocity(
|
||||
bodyPos, vel, dest, arrivalThreshold: 0.6f, dt: 0.016f, moveTowards: true);
|
||||
|
||||
Assert.Equal(0f, clamped.X);
|
||||
Assert.Equal(0f, clamped.Y);
|
||||
Assert.Equal(0.5f, clamped.Z); // gravity / Z handling unaffected
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClampApproachVelocity_FleeBranch_NoOp()
|
||||
{
|
||||
// moveTowards=false (flee): no overshoot risk, return velocity unchanged.
|
||||
var bodyPos = Vector3.Zero;
|
||||
var dest = new Vector3(0f, 1f, 0f);
|
||||
var vel = new Vector3(0f, -4f, 0f);
|
||||
|
||||
var clamped = RemoteMoveToDriver.ClampApproachVelocity(
|
||||
bodyPos, vel, dest, arrivalThreshold: 5f, dt: 0.5f, moveTowards: false);
|
||||
|
||||
Assert.Equal(vel, clamped);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OriginToWorld_AppliesLandblockGridShift()
|
||||
{
|
||||
// Cell ID 0xA8B4000E → landblock x=0xA8, y=0xB4. With live center
|
||||
// at (0xA9, 0xB4), that's one landblock west and zero north,
|
||||
// so origin (10, 20, 0) inside that landblock should map to
|
||||
// (10 - 192, 20 + 0, 0) = (-182, 20, 0) in render-world space.
|
||||
var w = RemoteMoveToDriver.OriginToWorld(
|
||||
originCellId: 0xA8B4000Eu,
|
||||
originX: 10f, originY: 20f, originZ: 0f,
|
||||
liveCenterLandblockX: 0xA9, liveCenterLandblockY: 0xB4);
|
||||
|
||||
Assert.Equal(-182f, w.X);
|
||||
Assert.Equal(20f, w.Y);
|
||||
Assert.Equal(0f, w.Z);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TurnRateFor_WalkingReturnsBaseRate()
|
||||
{
|
||||
// Retail: omega.z = ±π/2 × turn_speed (1.0) = π/2 rad/s ≈ 90°/s
|
||||
// Anchor: docs/research/named-retail/acclient_2013_pseudo_c.txt
|
||||
// CMotionInterp::apply_run_to_command 0x00527be0 only
|
||||
// multiplies under HoldKey.Run — walking is unscaled.
|
||||
float rate = RemoteMoveToDriver.TurnRateFor(running: false);
|
||||
Assert.Equal(MathF.PI / 2.0f, rate, precision: 5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TurnRateFor_RunningAppliesRunTurnFactor()
|
||||
{
|
||||
// Retail: omega.z = ±π/2 × turn_speed × run_turn_factor
|
||||
// run_turn_factor = 1.5f at 0x007c8914 (PDB-named).
|
||||
// apply_run_to_command (acclient_2013_pseudo_c.txt:305098)
|
||||
// multiplies turn_speed by 1.5f when input is TurnRight
|
||||
// under HoldKey.Run.
|
||||
float rate = RemoteMoveToDriver.TurnRateFor(running: true);
|
||||
Assert.Equal(MathF.PI / 2.0f * 1.5f, rate, precision: 5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TurnRateRadPerSec_BackCompatStillResolvesToWalkingRate()
|
||||
{
|
||||
// Existing call sites that haven't yet migrated to TurnRateFor
|
||||
// (e.g., RemoteMoveToDriver.Drive's TurnSpeed=1.0 callers) still
|
||||
// see the walking-rate constant. Same numerical value as
|
||||
// BaseTurnRateRadPerSec.
|
||||
Assert.Equal(RemoteMoveToDriver.BaseTurnRateRadPerSec,
|
||||
RemoteMoveToDriver.TurnRateRadPerSec, precision: 5);
|
||||
}
|
||||
}
|
||||
|
|
@ -6,41 +6,8 @@ namespace AcDream.Core.Tests.Physics;
|
|||
|
||||
public sealed class ServerControlledLocomotionTests
|
||||
{
|
||||
[Fact]
|
||||
public void PlanMoveToStart_SeedsImmediateRunCycle()
|
||||
{
|
||||
var plan = ServerControlledLocomotion.PlanMoveToStart();
|
||||
|
||||
Assert.True(plan.IsMoving);
|
||||
Assert.Equal(MotionCommand.RunForward, plan.Motion);
|
||||
Assert.Equal(1.0f, plan.SpeedMod);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlanMoveToStart_AppliesRetailRunRate()
|
||||
{
|
||||
var plan = ServerControlledLocomotion.PlanMoveToStart(
|
||||
moveToSpeed: 1.25f,
|
||||
runRate: 1.5f,
|
||||
canRun: true);
|
||||
|
||||
Assert.True(plan.IsMoving);
|
||||
Assert.Equal(MotionCommand.RunForward, plan.Motion);
|
||||
Assert.Equal(1.875f, plan.SpeedMod);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlanMoveToStart_UsesWalkWhenRunDisallowed()
|
||||
{
|
||||
var plan = ServerControlledLocomotion.PlanMoveToStart(
|
||||
moveToSpeed: 0.75f,
|
||||
runRate: 2.0f,
|
||||
canRun: false);
|
||||
|
||||
Assert.True(plan.IsMoving);
|
||||
Assert.Equal(MotionCommand.WalkForward, plan.Motion);
|
||||
Assert.Equal(0.75f, plan.SpeedMod);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlanFromVelocity_StopsBelowRetailNoiseThreshold()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue