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:
Erik 2026-07-03 12:18:05 +02:00
parent a144e87318
commit 7016b26ce7
10 changed files with 248 additions and 883 deletions

View file

@ -200,6 +200,12 @@ public sealed class PlayerMovementController
private bool _prevRunHeld;
private int _prevAutoWalkTurnDir;
// R4-V4: relocated from the DELETED RemoteMoveToDriver — B.6 auto-walk's
// last two dependencies on it. Both die with auto-walk in R4-V5.
private const float AutoWalkArrivalEpsilon = 0.05f;
private static float AutoWalkTurnRateFor(bool running)
=> running ? (MathF.PI / 2.0f) * 1.5f : (MathF.PI / 2.0f); // BaseTurnRate x RunTurnFactor
// Heartbeat timer.
// Cadence is 1.0 sec to match holtburger's
// AUTONOMOUS_POSITION_HEARTBEAT_INTERVAL and the retail trace
@ -533,10 +539,10 @@ public sealed class PlayerMovementController
/// for the rest of <see cref="Update"/> to consume.
///
/// <para>
/// Heading correction matches <see cref="RemoteMoveToDriver.Drive"/>
/// — ±<see cref="RemoteMoveToDriver.HeadingSnapToleranceRad"/>
/// Heading correction matches the deleted RemoteMoveToDriver.Drive
/// — ±its 20-degree HeadingSnapTolerance
/// snap-on-aligned, otherwise rotate at
/// <see cref="RemoteMoveToDriver.TurnRateRadPerSec"/>. Arrival
/// pi/2 rad/s. Arrival
/// predicate matches retail's
/// <c>MoveToManager::HandleMoveToPosition</c>: chase arrives at
/// <c>distanceToObject</c>; flee arrives at <c>minDistance</c>.
@ -618,7 +624,7 @@ public sealed class PlayerMovementController
(_autoWalkMoveTowards
&& dist <= arrivalThreshold)
|| (!_autoWalkMoveTowards
&& dist >= arrivalThreshold + RemoteMoveToDriver.ArrivalEpsilon);
&& dist >= arrivalThreshold + AutoWalkArrivalEpsilon);
// Step Yaw toward target. Convention from Update line 364:
// _body.Orientation = Quaternion.CreateFromAxisAngle(Z, Yaw - π/2),
@ -655,7 +661,7 @@ public sealed class PlayerMovementController
// run/walk decision (one-shot at chain start) drives the
// turn rate: running rotation is 50% faster per
// run_turn_factor at retail 0x007c8914.
float maxStep = RemoteMoveToDriver.TurnRateFor(_autoWalkInitiallyRunning) * dt;
float maxStep = AutoWalkTurnRateFor(_autoWalkInitiallyRunning) * dt;
float yawStep = MathF.Sign(delta) * MathF.Min(MathF.Abs(delta), maxStep);
Yaw += yawStep;
while (Yaw > MathF.PI) Yaw -= 2f * MathF.PI;
@ -1011,7 +1017,7 @@ public sealed class PlayerMovementController
if (!autoWalkConsumedMotion
&& _motion.InterpretedState.TurnCommand == MotionCommand.TurnRight)
{
Yaw -= RemoteMoveToDriver.BaseTurnRateRadPerSec
Yaw -= (MathF.PI / 2.0f) // BaseTurnRateRadPerSec (AP-9), ex-RemoteMoveToDriver
* _motion.InterpretedState.TurnSpeed * dt;
}
Yaw -= input.MouseDeltaX * MouseTurnSensitivity;

View file

@ -429,6 +429,21 @@ public sealed class GameWindow : IDisposable
/// </summary>
public System.Numerics.Vector3 ServerVelocity;
public bool HasServerVelocity;
/// <summary>R4-V4: the entity's verbatim retail MoveToManager
/// (server-directed movement), constructed + seam-bound by
/// EnsureRemoteMotionBindings beside the R2-Q5/R3 binds.</summary>
public AcDream.Core.Physics.Motion.MoveToManager? MoveTo;
// R4-V4 (pin P4): the minimal TargetTracker adapter state — the
// manager's setTarget/clearTarget seams store the tracked guid here;
// the per-tick block feeds HandleUpdateTarget(Ok/ExitWorld) from the
// live entity table. Full TargetManager port is R5 (register row).
public uint TrackedTargetGuid;
public float TrackedTargetRadius;
public System.Numerics.Vector3 TrackedTargetLastFedPos;
public bool TrackedTargetFedOnce;
public double TargetQuantum;
/// <summary>
/// True while a server MoveToObject/MoveToPosition packet is the
/// active locomotion source. Retail runs these through MoveToManager
@ -4191,7 +4206,7 @@ public sealed class GameWindow : IDisposable
/// the VectorUpdate path regardless of arrival order.
/// </summary>
private AcDream.Core.Physics.Motion.MotionTableDispatchSink? EnsureRemoteMotionBindings(
RemoteMotion rm, AnimatedEntity ae)
RemoteMotion rm, AnimatedEntity ae, uint serverGuid)
{
if (ae.Sequencer is null)
return rm.Sink;
@ -4218,6 +4233,44 @@ public sealed class GameWindow : IDisposable
// R3-W5: the per-op zero-tick flush (CPhysicsObj::CheckForCompletedMotions
// 0x0050fe30 -> MotionTableManager.CheckForCompletedMotions).
rm.Motion.CheckForCompletedMotions = ae.Sequencer.Manager.CheckForCompletedMotions;
// R4-V4: the verbatim MoveToManager, seam-bound per the V2 harness
// wiring (the conformance-tested reference). Positions are WORLD
// space on both sides (getPosition + the MovementStruct positions
// the UM router builds) — one consistent space, so the manager's
// distance/heading math matches the harness exactly.
var rmT = rm;
var mtBody = rm.Body;
rm.MoveTo = new AcDream.Core.Physics.Motion.MoveToManager(
rm.Motion,
stopCompletely: () => rmT.Motion.StopCompletely(),
getPosition: () => new AcDream.Core.Physics.Position(
rmT.CellId, mtBody.Position, mtBody.Orientation),
getHeading: () => AcDream.Core.Physics.Motion.MoveToMath.GetHeading(
mtBody.Orientation),
setHeading: (h, _) => mtBody.Orientation =
AcDream.Core.Physics.Motion.MoveToMath.SetHeading(mtBody.Orientation, h),
getOwnRadius: () => 0f, // pin P4 note: setup cylsphere radius lands with R5's TargetManager port
getOwnHeight: () => 0f,
contact: () => mtBody.OnWalkable,
isInterpolating: () => rmT.Interp.IsActive,
getVelocity: () => mtBody.Velocity,
getSelfId: () => serverGuid,
setTarget: (_, tlid, radius, _) =>
{
rmT.TrackedTargetGuid = tlid;
rmT.TrackedTargetRadius = radius;
rmT.TrackedTargetFedOnce = false;
},
clearTarget: () => rmT.TrackedTargetGuid = 0,
getTargetQuantum: () => rmT.TargetQuantum,
setTargetQuantum: q => rmT.TargetQuantum = q);
// TS-36 (remote side): the interp's interrupt seam is retail's
// interrupt_current_movement → MovementManager::CancelMoveTo(0x36)
// chain (V2's reentrancy tests prove the wiring is inert-safe).
rm.Motion.InterruptCurrentMovement =
() => rmT.MoveTo?.CancelMoveTo(
AcDream.Core.Physics.WeenieError.ActionCancelled);
return rm.Sink;
}
@ -4400,24 +4453,13 @@ public sealed class GameWindow : IDisposable
// otherwise → resolve class byte and use full cmd
float speedMod = update.MotionState.ForwardSpeed ?? 1f;
uint fullMotion;
if ((!command.HasValue || command.Value == 0)
&& update.MotionState.IsServerControlledMoveTo)
{
// Retail MoveToManager::BeginMoveForward calls
// MovementParameters::get_command (0x0052AA00), then
// _DoMotion -> adjust_motion. With CanRun and enough
// distance, WalkForward + HoldKey_Run becomes RunForward,
// and CMotionInterp::apply_run_to_command (0x00527BE0)
// multiplies speed by the packet's runRate.
var seed = AcDream.Core.Physics.ServerControlledLocomotion
.PlanMoveToStart(
update.MotionState.MoveToSpeed ?? 1f,
update.MotionState.MoveToRunRate ?? 1f,
update.MotionState.MoveToCanRun);
fullMotion = seed.Motion;
speedMod = seed.SpeedMod;
}
else if (!command.HasValue || command.Value == 0)
// R4-V4: the PlanMoveToStart seed is DELETED — MoveTo UMs no
// longer flow through the interpreted funnel at all (retail
// unpack_movement routes types 6-9 to MoveToManager; only type
// 0 does the interpreted-state copy). The manager's own
// BeginMoveForward -> get_command -> _DoMotion produces the
// cycle through the same sink every other motion uses.
if (!command.HasValue || command.Value == 0)
{
fullMotion = 0x41000003u;
}
@ -4510,7 +4552,7 @@ public sealed class GameWindow : IDisposable
&& update.MotionState.MoveToPath is { } pathData)
{
// Translate landblock-local origin → world space.
var destWorld = AcDream.Core.Physics.RemoteMoveToDriver
var destWorld = AcDream.Core.Physics.Motion.MoveToMath
.OriginToWorld(
pathData.OriginCellId,
pathData.OriginX,
@ -4563,13 +4605,97 @@ public sealed class GameWindow : IDisposable
// contact_allows_move gate, the retail mechanism behind the
// old K-fix17 guard).
//
// MoveTo packets (mt 6/7) reuse the PlanMoveToStart seed as
// the funnel's forward command (fullMotion/speedMod computed
// above) and additionally capture the path for the per-tick
// remote driver.
// R4-V4: retail unpack_movement dispatch (0x00524440) — the
// head-interrupt fires for EVERY movement type, then types
// 6/7/8/9 route to MoveToManager.PerformMovement (which
// cancels again itself — retail does both) while ONLY type 0
// flows through the interpreted-state funnel below.
if (_remoteDeadReckon.TryGetValue(update.Guid, out var remoteMot))
{
remoteMot.ServerMoveToActive = update.MotionState.IsServerControlledMoveTo;
var sink = EnsureRemoteMotionBindings(remoteMot, ae, update.Guid);
// unpack_movement head (@300566): interrupt + unstick.
remoteMot.Motion.InterruptCurrentMovement?.Invoke();
remoteMot.Motion.UnstickFromObject?.Invoke();
if (update.MotionState.IsServerControlledMoveTo
&& update.MotionState.MoveToPath is { } path
&& remoteMot.MoveTo is { } moveMgr)
{
// my_run_rate write (unpack_movement @300603).
if (update.MotionState.MoveToRunRate is { } mtRunRate)
remoteMot.Motion.MyRunRate = mtRunRate;
var destWorld = AcDream.Core.Physics.Motion.MoveToMath
.OriginToWorld(
path.OriginCellId, path.OriginX, path.OriginY, path.OriginZ,
_liveCenterX, _liveCenterY);
var mp = AcDream.Core.Physics.Motion.MovementParameters.FromWire(
path.Bitfield,
path.DistanceToObject,
path.MinDistance,
path.FailDistance,
update.MotionState.MoveToSpeed ?? 1f,
path.WalkRunThreshold,
path.DesiredHeading);
var ms = new AcDream.Core.Physics.MovementStruct
{
Params = mp,
};
// mt 6 with a resolvable target → MoveToObject (the
// P4 tracker feeds position updates per tick); else
// degrade to MoveToPosition at the wire origin (§2f).
if (update.MotionState.MovementType == 6
&& path.TargetGuid is { } tgtGuid
&& _entitiesByServerGuid.TryGetValue(tgtGuid, out var tgtEnt))
{
ms.Type = AcDream.Core.Physics.MovementType.MoveToObject;
ms.ObjectId = tgtGuid;
ms.TopLevelId = tgtGuid;
ms.Pos = new AcDream.Core.Physics.Position(
remoteMot.CellId, tgtEnt.Position,
System.Numerics.Quaternion.Identity);
}
else
{
ms.Type = AcDream.Core.Physics.MovementType.MoveToPosition;
ms.Pos = new AcDream.Core.Physics.Position(
remoteMot.CellId, destWorld,
System.Numerics.Quaternion.Identity);
}
moveMgr.PerformMovement(ms);
return;
}
if (update.MotionState.IsServerControlledTurnTo
&& update.MotionState.TurnToPath is { } turnPath
&& remoteMot.MoveTo is { } turnMgr)
{
var mp = AcDream.Core.Physics.Motion.MovementParameters.FromWireTurnTo(
turnPath.Bitfield,
turnPath.Speed,
turnPath.DesiredHeading);
var ms = new AcDream.Core.Physics.MovementStruct { Params = mp };
if (update.MotionState.MovementType == 8
&& turnPath.TargetGuid is { } turnTgt
&& _entitiesByServerGuid.TryGetValue(turnTgt, out var turnEnt))
{
ms.Type = AcDream.Core.Physics.MovementType.TurnToObject;
ms.ObjectId = turnTgt;
ms.TopLevelId = turnTgt;
ms.Pos = new AcDream.Core.Physics.Position(
remoteMot.CellId, turnEnt.Position,
System.Numerics.Quaternion.Identity);
}
else
{
ms.Type = AcDream.Core.Physics.MovementType.TurnToHeading;
}
turnMgr.PerformMovement(ms);
return;
}
// [FWD_WIRE] + observed-velocity history invalidation on
// a forward-command change (pre-S2 behavior, unchanged:
@ -4588,30 +4714,9 @@ public sealed class GameWindow : IDisposable
remoteMot.PrevServerPosTime = 0.0;
}
if (update.MotionState.IsServerControlledMoveTo
&& update.MotionState.MoveToPath is { } path)
{
remoteMot.MoveToDestinationWorld = AcDream.Core.Physics.RemoteMoveToDriver
.OriginToWorld(
path.OriginCellId, path.OriginX, path.OriginY, path.OriginZ,
_liveCenterX, _liveCenterY);
remoteMot.MoveToMinDistance = path.MinDistance;
remoteMot.MoveToDistanceToObject = path.DistanceToObject;
remoteMot.MoveToMoveTowards = update.MotionState.MoveTowards;
remoteMot.HasMoveToDestination = true;
remoteMot.LastMoveToPacketTime =
(System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds;
}
else if (!update.MotionState.IsServerControlledMoveTo)
{
// Off MoveTo — clear stale destination so the
// per-tick driver doesn't keep steering.
remoteMot.HasMoveToDestination = false;
}
// Build the funnel input. fullMotion/speedMod already
// encode retail's absent-field forward defaults (null/0
// → Ready; MoveTo → PlanMoveToStart seed). Style:
// → Ready; mt 6-9 never reach here post-V4). Style:
// retail's InterpretedMotionState::UnPack (0x0051f400)
// defaults an absent stance to NonCombat 0x8000003D —
// NOT keep-current (S0 trace: every empty UM applied
@ -4670,11 +4775,7 @@ public sealed class GameWindow : IDisposable
// R2-Q5/R3-W4: funnel dispatches go STRAIGHT into the
// motion-table stack (GetObjectSequence + is_allowed
// decide). The sink is PERSISTENT per remote and also
// bound as Motion.DefaultSink + the RemoveLink/InitTables
// seams (EnsureRemoteMotionBindings) so LeaveGround/
// HitGround dispatch through the same backend.
var sink = EnsureRemoteMotionBindings(remoteMot, ae);
// decide) — mt-0 only post-V4 (types 6-9 returned above).
remoteMot.Motion.MoveToInterpretedState(ims, sink);
}
}
@ -4854,7 +4955,7 @@ public sealed class GameWindow : IDisposable
&& _animatedEntities.TryGetValue(ent.Id, out var ae)
&& ae.Sequencer is not null)
{
EnsureRemoteMotionBindings(rm, ae);
EnsureRemoteMotionBindings(rm, ae, update.Guid);
rm.Motion.LeaveGround();
rm.Body.Velocity = update.Velocity;
rm.Body.Omega = update.Omega;
@ -4910,11 +5011,10 @@ public sealed class GameWindow : IDisposable
{
if (rm.Airborne) return;
if (ae.Sequencer is null) return;
// MoveTo packets already seeded the retail speed/runRate cycle.
// Keep UpdatePosition-derived velocity for render position only;
// using it to choose the cycle reverts fast chases back to slow
// velocity-estimated animation.
if (rm.ServerMoveToActive) return;
// R4-V4: an active MoveToManager owns the cycle (BeginMoveForward's
// get_command dispatch). Velocity-estimated cycle planning would
// fight it — same rationale as the pre-V4 ServerMoveToActive guard.
if (rm.MoveTo is { MovementTypeState: not AcDream.Core.Physics.MovementType.Invalid }) return;
if (IsPlayerGuid(serverGuid))
{
@ -4960,7 +5060,7 @@ public sealed class GameWindow : IDisposable
+ $"|v|={velocity.Length():F2} "
+ $"-> motion=0x{plan.Motion:X8} speedMod={plan.SpeedMod:F2} "
+ $"prev=0x{currentMotion:X8} "
+ $"airborne={rm.Airborne} moveTo={rm.ServerMoveToActive}");
+ $"airborne={rm.Airborne} moveTo={rm.MoveTo?.MovementTypeState ?? AcDream.Core.Physics.MovementType.Invalid}");
}
ae.Sequencer.SetCycle(style, plan.Motion, plan.SpeedMod);
}
@ -5326,7 +5426,6 @@ public sealed class GameWindow : IDisposable
// carries no stop information for our ACE.
if (svel.LengthSquared() < 0.04f)
{
rmState.ServerMoveToActive = false;
rmState.Motion.StopCompletely();
if (_animatedEntities.TryGetValue(entity.Id, out var aeForStop)
&& aeForStop.Sequencer is not null)
@ -9591,93 +9690,52 @@ public sealed class GameWindow : IDisposable
rm.Body.Velocity = rm.ServerVelocity;
}
}
else if (!IsPlayerGuid(serverGuid) && rm.ServerMoveToActive
&& rm.HasMoveToDestination)
{
// Phase L.1c port of retail MoveToManager per-tick
// steering (HandleMoveToPosition @ 0x00529d80).
// Steer body orientation toward the latest
// server-supplied destination, then let
// apply_current_movement set Velocity from the
// RunForward cycle through the now-correct heading.
// Stale-destination guard (2026-04-28): if no
// MoveTo packet has refreshed the destination
// recently, the entity has likely left our
// streaming view or the server cancelled the
// move without us seeing the cancel UM. Continuing
// to steer toward a stale point produces the
// "monster runs in place after popping back into
// view" symptom. Clear and stand down.
double moveToAge = nowSec - rm.LastMoveToPacketTime;
if (moveToAge > AcDream.Core.Physics.RemoteMoveToDriver.StaleDestinationSeconds)
{
rm.HasMoveToDestination = false;
rm.Body.Velocity = System.Numerics.Vector3.Zero;
}
else
{
var driveResult = AcDream.Core.Physics.RemoteMoveToDriver
.Drive(
rm.Body.Position,
rm.Body.Orientation,
rm.MoveToDestinationWorld,
rm.MoveToMinDistance,
rm.MoveToDistanceToObject,
(float)dt,
rm.MoveToMoveTowards,
out var steeredOrientation);
rm.Body.Orientation = steeredOrientation;
if (driveResult == AcDream.Core.Physics.RemoteMoveToDriver
.DriveResult.Arrived)
{
// Within arrival window — zero velocity until the
// next MoveTo packet refreshes the destination
// (or the server explicitly stops us with an
// interpreted-motion UM cmd=Ready).
rm.Body.Velocity = System.Numerics.Vector3.Zero;
}
else
{
// Steering active — apply_current_movement reads
// InterpretedState.ForwardCommand=RunForward (set
// when the MoveTo packet arrived) and emits
// velocity along +Y in body local space. Our
// updated orientation rotates that into the right
// world direction toward the target.
rm.Motion.apply_current_movement(cancelMoveTo: false, allowJump: false);
// Clamp horizontal velocity so we don't overshoot
// the arrival threshold during the final tick of
// approach. Without this, a 4 m/s body advances
// ~6 cm/tick and visibly runs slightly through
// the target before the swing UM lands.
float arrivalThreshold = rm.MoveToMoveTowards
? rm.MoveToDistanceToObject
: rm.MoveToMinDistance;
rm.Body.Velocity = AcDream.Core.Physics.RemoteMoveToDriver
.ClampApproachVelocity(
rm.Body.Position,
rm.Body.Velocity,
rm.MoveToDestinationWorld,
arrivalThreshold,
(float)dt,
rm.MoveToMoveTowards);
}
}
}
else if (!IsPlayerGuid(serverGuid) && rm.ServerMoveToActive)
{
// MoveTo flag set but we haven't seen a path payload
// yet (e.g. truncated packet, or a brand-new entity
// whose first cycle UM is still in flight). Hold
// velocity at zero — same conservative stance as the
// 882a07c stabilizer for incomplete state.
rm.Body.Velocity = System.Numerics.Vector3.Zero;
}
else
{
// R4-V4: the retail MoveToManager drives
// server-directed movement per tick — the P4
// TargetTracker adapter feeds HandleUpdateTarget
// from the live entity table (full TargetManager
// port is R5; register row), then UseTime runs
// HandleMoveToPosition/HandleTurnToHeading
// (steering + arrival + fail-distance), and
// apply_current_movement recomputes velocity from
// whatever the manager dispatched — the same
// per-tick shape ordinary locomotion uses.
if (!IsPlayerGuid(serverGuid) && rm.MoveTo is { } mtm)
{
if (rm.TrackedTargetGuid != 0)
{
if (_entitiesByServerGuid.TryGetValue(rm.TrackedTargetGuid, out var trackedEnt))
{
var tpos = trackedEnt.Position;
if (!rm.TrackedTargetFedOnce
|| System.Numerics.Vector3.Distance(tpos, rm.TrackedTargetLastFedPos)
> rm.TrackedTargetRadius)
{
rm.TrackedTargetFedOnce = true;
rm.TrackedTargetLastFedPos = tpos;
var tp = new AcDream.Core.Physics.Position(
rm.CellId, tpos, System.Numerics.Quaternion.Identity);
mtm.HandleUpdateTarget(new AcDream.Core.Physics.Motion.TargetInfo(
rm.TrackedTargetGuid,
AcDream.Core.Physics.Motion.TargetStatus.Ok,
tp, tp));
}
}
else
{
var lp = new AcDream.Core.Physics.Position(
rm.CellId, rm.TrackedTargetLastFedPos,
System.Numerics.Quaternion.Identity);
mtm.HandleUpdateTarget(new AcDream.Core.Physics.Motion.TargetInfo(
rm.TrackedTargetGuid,
AcDream.Core.Physics.Motion.TargetStatus.ExitWorld,
lp, lp));
}
}
mtm.UseTime();
}
rm.Motion.apply_current_movement(cancelMoveTo: false, allowJump: false);
}
}

View file

@ -338,7 +338,8 @@ public static class CreateObject
float MinDistance,
float FailDistance,
float WalkRunThreshold,
float DesiredHeading);
float DesiredHeading,
uint Bitfield = 0); // R4-V4: the raw UnPackNet flags dword, feeds MovementParameters.FromWire
/// <summary>
/// R4-V3 (closes M7) — path-control payload of a server-controlled

View file

@ -406,7 +406,8 @@ public static class UpdateMotion
minDistance,
failDistance,
walkRunThreshold,
desiredHeading);
desiredHeading,
movementParameters ?? 0u); // R4-V4: the raw flags dword -> FromWire
return true;
}

View file

@ -198,4 +198,26 @@ public static class MoveToMath
float edgeDist = centerDist - ownRadius - targetRadius;
return edgeDist > 0f ? edgeDist : 0f;
}
/// <summary>
/// Landblock-local wire origin → world space (verbatim relocation from
/// the deleted <c>RemoteMoveToDriver.OriginToWorld</c>, R4-V4): MoveTo /
/// TurnTo packets carry positions block-local; acdream's streaming world
/// re-centers on a live-center landblock grid.
/// </summary>
public static Vector3 OriginToWorld(
uint originCellId,
float originX,
float originY,
float originZ,
int liveCenterLandblockX,
int liveCenterLandblockY)
{
int lbX = (int)((originCellId >> 24) & 0xFFu);
int lbY = (int)((originCellId >> 16) & 0xFFu);
return new Vector3(
originX + (lbX - liveCenterLandblockX) * 192f,
originY + (lbY - liveCenterLandblockY) * 192f,
originZ);
}
}

View file

@ -1,344 +0,0 @@
using System;
using System.Numerics;
namespace AcDream.Core.Physics;
/// <summary>
/// Per-tick steering for server-controlled remote creatures while a
/// MoveToObject (movementType 6) or MoveToPosition (movementType 7) packet
/// is the active locomotion source.
///
/// <para>
/// Replaces the 882a07c-era "hold body Velocity at zero during MoveTo"
/// stabilizer. With the full MoveTo path payload now captured on
/// <see cref="AcDream.Core.Net.Messages.CreateObject.MoveToPathData"/>,
/// the body solver has the destination + heading + thresholds it needs to
/// run the retail per-tick loop instead of waiting for sparse
/// UpdatePosition snap corrections.
/// </para>
///
/// <para>
/// Retail references:
/// <list type="bullet">
/// <item><description>
/// <c>MoveToManager::HandleMoveToPosition</c> (<c>0x00529d80</c>) — the
/// per-tick driver. Computes heading-to-target, fires an aux
/// <c>TurnLeft</c>/<c>TurnRight</c> command when |delta| &gt; 20°, snaps
/// orientation when within tolerance, and tests arrival via
/// <c>dist &lt;= min_distance</c> (chase) or
/// <c>dist &gt;= distance_to_object</c> (flee).
/// </description></item>
/// <item><description>
/// <c>MoveToManager::_DoMotion</c> / <c>_StopMotion</c> route turn
/// commands through <c>CMotionInterp::DoInterpretedMotion</c> — i.e.
/// MoveToManager itself does NOT touch the body. The body's actual
/// velocity comes from <c>CMotionInterp::apply_current_movement</c>
/// reading <c>InterpretedState.ForwardCommand = RunForward</c> and
/// emitting <c>velocity.Y = RunAnimSpeed × speedMod</c>, transformed by
/// the body's orientation.
/// </description></item>
/// </list>
/// </para>
///
/// <para>
/// Acdream port scope: minimum viable subset. We skip target re-tracking
/// (server re-emits MoveTo every ~1 s with refreshed Origin), sticky/
/// StickTo, fail-distance progress detector, and the sphere-cylinder
/// distance variant — all server-side concerns the local body doesn't need
/// to model. We DO port heading-to-target, the ±20° aux-turn tolerance
/// (with ACE's <c>set_heading(true)</c> snap-on-aligned fudge), and
/// arrival detection via <c>min_distance</c>.
/// </para>
///
/// <para>
/// Arrival predicates: retail and ACE AGREE — chase arrives at
/// <c>dist &lt;= distance_to_object</c>, flee arrives at
/// <c>dist &gt;= min_distance</c> (adjudicated in
/// <c>docs/research/2026-07-03-r4-moveto/r4-ace-moveto.md</c> §1; see the
/// arrival comment in <see cref="Drive"/>). An earlier revision of this doc
/// claimed ACE swapped the two predicates — that was wrong; do not port
/// against it.
/// </para>
/// </summary>
public static class RemoteMoveToDriver
{
/// <summary>
/// Heading tolerance below which we snap orientation directly to the
/// target heading (ACE's <c>set_heading(target, true)</c>
/// server-tic-rate fudge). Above tolerance we rotate at
/// <see cref="TurnRateRadPerSec"/>. Retail value (line 307251 of
/// <c>acclient_2013_pseudo_c.txt</c>) is 20°.
/// </summary>
public const float HeadingSnapToleranceRad = 20.0f * MathF.PI / 180.0f;
/// <summary>
/// Default angular rate for in-motion heading correction when delta
/// exceeds <see cref="HeadingSnapToleranceRad"/>. Picked to match
/// ACE's <c>TurnSpeed</c> default of <c>π/2</c> rad/s for monsters;
/// when the per-creature value differs, the future port can wire it
/// in via the <c>TurnSpeed</c> field on InterpretedMotionState.
/// </summary>
public const float TurnRateRadPerSec = MathF.PI / 2.0f;
/// <summary>
/// Retail base turn rate for the player Humanoid when turn_speed
/// scalar = 1.0. Convention default <c>omega.z = ±π/2 rad/s</c>
/// derived from <c>add_motion</c> at <c>0x005224b0</c> + the
/// HasOmega-cleared MotionData fallback documented in
/// <c>AnimationSequencer.cs:734-741</c>. ~90°/s.
/// </summary>
public const float BaseTurnRateRadPerSec = MathF.PI / 2.0f;
/// <summary>
/// Retail's <c>run_turn_factor</c> constant at <c>0x007c8914</c>
/// (PDB-named). <c>CMotionInterp::apply_run_to_command</c>
/// equivalent (decomp <c>0x00527be0</c>, line 305098 of
/// <c>acclient_2013_pseudo_c.txt</c>) multiplies <c>turn_speed</c>
/// by 1.5 when <c>HoldKey.Run</c> is active on a TurnRight/TurnLeft
/// command. Effect: running rotation is 50 % faster than walking.
/// </summary>
public const float RunTurnFactor = 1.5f;
/// <summary>
/// Retail-faithful local-player turn rate.
/// <list type="bullet">
/// <item><b>Walking</b>: <c>BaseTurnRateRadPerSec</c> ≈ 90°/s.</item>
/// <item><b>Running</b>: <c>BaseTurnRateRadPerSec × RunTurnFactor</c>
/// ≈ 135°/s.</item>
/// </list>
/// Replaces the fixed <c>TurnRateRadPerSec</c> for paths that have
/// access to the player's run/walk state (keyboard A/D, auto-walk
/// overlay turn-first). NPC/monster remotes that lack the
/// information continue to use the constant which equals
/// <c>BaseTurnRateRadPerSec</c>.
/// </summary>
public static float TurnRateFor(bool running)
=> running ? BaseTurnRateRadPerSec * RunTurnFactor
: BaseTurnRateRadPerSec;
/// <summary>
/// Float-comparison slack for the arrival predicate. With
/// <c>min_distance == 0</c> in a chase packet, exact equality is
/// unreachable due to integration wobble; this epsilon prevents the
/// driver from over-shooting by a sub-meter and snap-flipping back.
/// </summary>
public const float ArrivalEpsilon = 0.05f;
/// <summary>
/// Maximum staleness (seconds) of the most recent MoveTo packet
/// before the driver gives up steering. ACE re-emits MoveTo at ~1 Hz
/// during active chase; if no fresh packet arrives for this long,
/// the entity has likely either left our streaming view, switched
/// to a non-MoveTo motion the server's broadcast didn't reach us
/// for, or had its move cancelled server-side without our seeing
/// the cancel UM. In any of those cases, continuing to drive the
/// body toward a stale destination produces the "monster runs in
/// place after popping back into view" symptom (2026-04-28).
/// 1.5 s gives us comfortable margin over the ~1 s emit cadence
/// while still failing fast on real loss-of-state.
/// </summary>
public const double StaleDestinationSeconds = 1.5;
public enum DriveResult
{
/// <summary>Within arrival window — caller should zero velocity.</summary>
Arrived,
/// <summary>Steering active — caller should let
/// <c>apply_current_movement</c> set body velocity from the cycle.</summary>
Steering,
}
/// <summary>
/// Steer body orientation toward <paramref name="destinationWorld"/>
/// and report whether the body has arrived or should keep running.
/// Pure function — emits the updated orientation via
/// <paramref name="newOrientation"/> (the input is not mutated; the
/// caller assigns the new value back to its body).
/// </summary>
/// <param name="minDistance">
/// <c>min_distance</c> from the wire's MovementParameters block —
/// retail's <c>HandleMoveToPosition</c> chase-arrival threshold.
/// </param>
/// <param name="distanceToObject">
/// <c>distance_to_object</c> from the wire — ACE's chase-arrival
/// threshold (default 0.6 m, the melee range). The actual arrival
/// gate is <c>max(minDistance, distanceToObject)</c>: retail-faithful
/// when retail sends <c>min_distance</c> &gt; 0, ACE-compatible when
/// ACE puts the value in <c>distance_to_object</c> with
/// <c>min_distance == 0</c>. Without this, ACE's <c>min_distance==0</c>
/// chase packets never arrive — the body keeps re-targeting around
/// the player at melee range and visibly oscillates between facings,
/// which is the user-reported "monster keeps running in different
/// directions when it should be attacking" symptom (2026-04-28).
/// </param>
public static DriveResult Drive(
Vector3 bodyPosition,
Quaternion bodyOrientation,
Vector3 destinationWorld,
float minDistance,
float distanceToObject,
float dt,
bool moveTowards,
out Quaternion newOrientation)
{
// Horizontal distance only — server owns Z, our body Z is
// hard-snapped to the latest UpdatePosition.
float dx = destinationWorld.X - bodyPosition.X;
float dy = destinationWorld.Y - bodyPosition.Y;
float dist = MathF.Sqrt(dx * dx + dy * dy);
// Arrival predicate per retail MoveToManager::HandleMoveToPosition
// (acclient_2013_pseudo_c.txt:307289-307320) and ACE
// MoveToManager.cs:476:
//
// chase (MoveTowards): dist <= distance_to_object
// flee (MoveAway): dist >= min_distance
//
// (My earlier <c>max(MinDistance, DistanceToObject)</c> was a
// defensive guess; cross-checked with two independent research
// agents against the named retail decomp + ACE port + holtburger,
// the chase threshold is unambiguously DistanceToObject —
// MinDistance is the FLEE arrival threshold. ACE's wire defaults
// give MinDistance=0, DistanceToObject=0.6 — the body should stop
// at melee range, not run to zero.)
float arrivalThreshold = moveTowards ? distanceToObject : minDistance;
if (moveTowards && dist <= arrivalThreshold + ArrivalEpsilon)
{
newOrientation = bodyOrientation;
return DriveResult.Arrived;
}
if (!moveTowards && dist >= arrivalThreshold - ArrivalEpsilon)
{
newOrientation = bodyOrientation;
return DriveResult.Arrived;
}
// Degenerate — already on target horizontally; preserve heading.
if (dist < 1e-4f)
{
newOrientation = bodyOrientation;
return DriveResult.Steering;
}
// Body's local-forward is +Y (see MotionInterpreter.get_state_velocity
// at line 605-616: velocity.Y = (Walk/Run)AnimSpeed × ForwardSpeed).
// World forward = Transform((0,1,0), orientation). Yaw extracted
// via atan2(-worldFwd.X, worldFwd.Y) so yaw = 0 ↔ orientation = Identity.
var localForward = new Vector3(0f, 1f, 0f);
var worldForward = Vector3.Transform(localForward, bodyOrientation);
float currentYaw = MathF.Atan2(-worldForward.X, worldForward.Y);
// Desired heading: face the target. (dx, dy) is the world-space
// offset to the target. With local-forward=+Y we want yaw such
// that Transform((0,1,0), R_Z(yaw)) = (dx, dy)/dist; that solves
// to yaw = atan2(-dx, dy).
float desiredYaw = MathF.Atan2(-dx, dy);
float delta = WrapPi(desiredYaw - currentYaw);
if (MathF.Abs(delta) <= HeadingSnapToleranceRad)
{
// ACE's set_heading(target, true) — sync to server-tic-rate.
// We have the same sparse-UP problem ACE does, so the same
// fudge applies.
newOrientation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, desiredYaw);
}
else
{
// Retail BeginTurnToHeading / HandleMoveToPosition aux turn:
// rotate at TurnRate clamped to dt, in the shorter direction.
float maxStep = TurnRateRadPerSec * dt;
float step = MathF.Sign(delta) * MathF.Min(MathF.Abs(delta), maxStep);
// Apply incremental yaw around world +Z (preserving any
// server-supplied pitch/roll from the latest UpdatePosition).
var deltaQuat = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, step);
newOrientation = Quaternion.Normalize(deltaQuat * bodyOrientation);
}
return DriveResult.Steering;
}
/// <summary>
/// Convert a landblock-local Origin from a MoveTo packet
/// (<see cref="AcDream.Core.Net.Messages.CreateObject.MoveToPathData"/>)
/// into acdream's render world space using the same arithmetic as
/// <c>OnLivePositionUpdated</c>: shift by the landblock-grid offset
/// from the live-mode center.
/// </summary>
public static Vector3 OriginToWorld(
uint originCellId,
float originX,
float originY,
float originZ,
int liveCenterLandblockX,
int liveCenterLandblockY)
{
int lbX = (int)((originCellId >> 24) & 0xFFu);
int lbY = (int)((originCellId >> 16) & 0xFFu);
return new Vector3(
originX + (lbX - liveCenterLandblockX) * 192f,
originY + (lbY - liveCenterLandblockY) * 192f,
originZ);
}
/// <summary>
/// Cap horizontal velocity so the body lands exactly at
/// <paramref name="arrivalThreshold"/> rather than overshooting past
/// it during the final tick of approach. Without this clamp, a body
/// running at <c>RunAnimSpeed × speedMod ≈ 4 m/s</c> can overshoot
/// the 0.6 m arrival window by up to one tick's advance (~6 cm at
/// 60 fps) — visible as the creature "running slightly through" the
/// player it's about to attack (user-reported 2026-04-28).
///
/// <para>
/// The clamp is a strict scale-down of the horizontal component
/// (X/Y); the vertical component (Z) is left to gravity / terrain
/// handling. <paramref name="moveTowards"/> false (flee branch) is a
/// no-op since fleeing has no overshoot risk — the body wants to
/// move AWAY from the destination.
/// </para>
/// </summary>
public static Vector3 ClampApproachVelocity(
Vector3 bodyPosition,
Vector3 currentVelocity,
Vector3 destinationWorld,
float arrivalThreshold,
float dt,
bool moveTowards)
{
if (!moveTowards || dt <= 0f) return currentVelocity;
float dx = destinationWorld.X - bodyPosition.X;
float dy = destinationWorld.Y - bodyPosition.Y;
float dist = MathF.Sqrt(dx * dx + dy * dy);
float remaining = MathF.Max(0f, dist - arrivalThreshold);
float vxy = MathF.Sqrt(currentVelocity.X * currentVelocity.X
+ currentVelocity.Y * currentVelocity.Y);
if (vxy < 1e-3f) return currentVelocity;
float advance = vxy * dt;
if (advance <= remaining) return currentVelocity;
// Already inside or right at the threshold: zero horizontal
// velocity, keep Z. (The arrival predicate in Drive() should
// have fired this tick, but this is the belt-and-braces guard.)
if (remaining < 1e-3f)
return new Vector3(0f, 0f, currentVelocity.Z);
float scale = remaining / advance;
return new Vector3(
currentVelocity.X * scale,
currentVelocity.Y * scale,
currentVelocity.Z);
}
/// <summary>Wrap an angle in radians to [-π, π].</summary>
private static float WrapPi(float r)
{
const float TwoPi = MathF.PI * 2f;
r %= TwoPi;
if (r > MathF.PI) r -= TwoPi;
if (r < -MathF.PI) r += TwoPi;
return r;
}
}

View file

@ -32,24 +32,10 @@ public static class ServerControlledLocomotion
public const float MinSpeedMod = 0.25f;
public const float MaxSpeedMod = 3.00f;
// Retail MoveToManager::BeginMoveForward -> MovementParameters::get_command
// (0x0052AA00) seeds forward motion before the next position update.
public static LocomotionCycle PlanMoveToStart(
float moveToSpeed = 1f,
float runRate = 1f,
bool canRun = true)
{
moveToSpeed = SanitizePositive(moveToSpeed);
runRate = SanitizePositive(runRate);
if (!canRun)
return new LocomotionCycle(MotionCommand.WalkForward, moveToSpeed, true);
return new LocomotionCycle(
MotionCommand.RunForward,
moveToSpeed * runRate,
true);
}
// R4-V4: PlanMoveToStart DELETED - MoveTo UMs route to the verbatim
// MoveToManager (BeginMoveForward -> get_command -> _DoMotion produces
// the cycle through the same sink every other motion uses).
// PlanFromVelocity below survives (M16 register row, retires in R6).
public static LocomotionCycle PlanFromVelocity(Vector3 worldVelocity)
{