diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index d46ccf37..198ec19a 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -9959,6 +9959,9 @@ public sealed class GameWindow : IDisposable
| AcDream.Core.Physics.TransientStateFlags.Active;
if (!IsPlayerGuid(serverGuid) && rm.HasServerVelocity)
{
+ if (Environment.GetEnvironmentVariable("ACDREAM_MVTO_DIAG") == "1")
+ Console.WriteLine(System.FormattableString.Invariant(
+ $"[npc-tick] guid={serverGuid:X8} branch=SERVERVEL (skips UseTime) mtState={rm.MoveTo?.MovementTypeState}"));
double velocityAge = nowSec - rm.LastServerPosTime;
if (velocityAge > ServerControlledVelocityStaleSeconds)
{
@@ -9979,42 +9982,45 @@ public sealed class GameWindow : IDisposable
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.
+ // server-directed movement per tick — UseTime runs
+ // HandleMoveToPosition/HandleTurnToHeading (steering
+ // + arrival + fail-distance), dispatching its OWN
+ // per-node locomotion (turn / RunForward) through the
+ // sink. That per-node dispatch is the only motion the
+ // per-tick update should issue for a remote.
+ if (Environment.GetEnvironmentVariable("ACDREAM_MVTO_DIAG") == "1")
+ Console.WriteLine(System.FormattableString.Invariant(
+ $"[npc-tick] guid={serverGuid:X8} branch=MOVETO (UseTime runs) mtState={rm.MoveTo?.MovementTypeState}"));
TickRemoteMoveTo(rm);
- rm.Motion.apply_current_movement(cancelMoveTo: false, allowJump: false);
- // #170 glide fix (2026-07-04): refresh the body's
- // translation velocity from the current interpreted
- // state, matching retail. apply_current_movement's
- // SINK path (all remotes have a DefaultSink) only
- // dispatches the ANIMATION — the
- // get_state_velocity → set_local_velocity write
- // that retail's apply_current_movement performs
- // lives ONLY in acdream's no-sink fallback
- // (MotionInterpreter.ApplyCurrentMovementInterpreted,
- // ~1702), so a remote's body.Velocity is never
- // recomputed and goes STALE. get_state_velocity
- // (0x00527d50, already a verbatim port) returns
- // WalkForward→3.12×spd, RunForward→4.0×spd, and 0
- // for every other forward command — so when a
- // chasing creature's ForwardCommand switches to an
- // attack (0x1000006x, action-class), the body
- // stops instead of coasting at the last run
- // velocity while playing an idle+attack pose (the
- // user-reported "glides after me"). Grounded only
- // (this branch already asserted Contact+OnWalkable);
- // airborne velocity is owned by the jump arc.
- // Confirmed vs a live ACDREAM_DUMP_MOTION capture of
- // Mite Scamp 0x80000244 (66 mt-0 attack UMs carrying
- // ForwardCommand=0x62/63/64 vs 2 mt-6 chase UMs).
+ // #170 ROOT FIX (2026-07-04): the per-frame
+ // rm.Motion.apply_current_movement(...) call that used
+ // to be here is DELETED. For a remote (which has a
+ // DefaultSink) it re-ran ApplyInterpretedMovement EVERY
+ // FRAME, re-dispatching the whole interpreted state —
+ // stance (0x8000003C), forward=attack, sidestep/turn
+ // stops — and each successful DoInterpretedMotion
+ // appends a CMotionInterp.pending_motions node. Those
+ // nodes barely drain for a remote, so pending_motions
+ // EXPLODED to ~1.3M entries (live capture: add=1.37M vs
+ // done=5.7K; ~671K of them the unchanged stance).
+ // MotionsPending() then stayed permanently true, so
+ // MoveToManager.BeginTurnToHeading (0x00529b90,
+ // `if (motions_pending) return`) could never start the
+ // chase turn → the chase never reached BeginMoveForward
+ // → RunForward, so the creature SLID in an idle+attack
+ // pose instead of running. Retail dispatches per MOTION
+ // EVENT (per UM), never per frame — a live cdb drain
+ // trace showed retail add_to_queue == MotionDone (queue
+ // stays shallow). The motion is already dispatched per
+ // UM by the funnel (MoveToInterpretedState) and by the
+ // MoveToManager per node above; the only thing the
+ // deleted call still provided was body velocity, which
+ // the get_state_velocity refresh below performs
+ // directly. get_state_velocity (0x00527d50, verbatim)
+ // returns WalkForward→3.12×spd, RunForward→4.0×spd, 0
+ // otherwise; grounded-only (this branch already asserted
+ // Contact+OnWalkable), airborne velocity owns the jump.
if (rm.Body.OnWalkable)
rm.Body.set_local_velocity(
rm.Motion.get_state_velocity(),
diff --git a/src/AcDream.Core/Physics/Motion/MoveToManager.cs b/src/AcDream.Core/Physics/Motion/MoveToManager.cs
index ac943c80..37e3cce8 100644
--- a/src/AcDream.Core/Physics/Motion/MoveToManager.cs
+++ b/src/AcDream.Core/Physics/Motion/MoveToManager.cs
@@ -738,6 +738,12 @@ public sealed class MoveToManager
/// (/ = dist,
/// both times = now).
///
+ // #170 temp diag (ACDREAM_MVTO_DIAG): trace the MoveTo dispatch/re-drive/cancel
+ // events to compare acdream's run-cycle install cadence against a live retail
+ // cdb count. Strip once #170 is understood.
+ private static readonly bool s_mvtoDiag =
+ System.Environment.GetEnvironmentVariable("ACDREAM_MVTO_DIAG") == "1";
+
public void BeginMoveForward()
{
if (!HasPhysicsObj)
@@ -756,6 +762,10 @@ public sealed class MoveToManager
Params.GetCommand(dist, heading, out uint cmd, out HoldKey holdKey, out bool movingAway);
+ if (s_mvtoDiag)
+ System.Console.WriteLine(System.FormattableString.Invariant(
+ $"[mvto] BeginMoveForward dist={dist:F2} cmd=0x{cmd:X8} hold={holdKey} movingAway={movingAway}"));
+
if (cmd == 0)
{
RemovePendingActionsHead();
@@ -816,6 +826,10 @@ public sealed class MoveToManager
return;
}
+ if (s_mvtoDiag)
+ System.Console.WriteLine(System.FormattableString.Invariant(
+ $"[mvto] BeginTurnToHeading motionsPending={_interp.MotionsPending()} curCmd=0x{CurrentCommand:X8}"));
+
if (_interp.MotionsPending())
{
return;
@@ -952,6 +966,10 @@ public sealed class MoveToManager
///
public void UseTime()
{
+ if (s_mvtoDiag && (MovementTypeState != MovementType.Invalid || _pendingActions.First is not null))
+ System.Console.WriteLine(System.FormattableString.Invariant(
+ $"[mvto] UseTime enter contact={_contact()} pending={_pendingActions.Count} mtState={MovementTypeState} init={Initialized} hasPhys={HasPhysicsObj}"));
+
if (!HasPhysicsObj || !_contact()) return;
if (_pendingActions.First is null) return;
@@ -960,6 +978,9 @@ public sealed class MoveToManager
if (!objectMoveGate) return;
MovementType type = _pendingActions.First.Value.Type;
+ if (s_mvtoDiag)
+ System.Console.WriteLine(System.FormattableString.Invariant(
+ $"[mvto] UseTime dispatch node={type} mtState={MovementTypeState}"));
if (type == MovementType.MoveToPosition)
{
HandleMoveToPosition();
@@ -1228,6 +1249,10 @@ public sealed class MoveToManager
///
public void HandleUpdateTarget(TargetInfo info)
{
+ if (s_mvtoDiag)
+ System.Console.WriteLine(System.FormattableString.Invariant(
+ $"[mvto] HandleUpdateTarget objId=0x{info.ObjectId:X8} myTgt=0x{TopLevelObjectId:X8} match={(TopLevelObjectId == info.ObjectId)} init={Initialized} mtState={MovementTypeState} status={info.Status}"));
+
if (!HasPhysicsObj)
{
CancelMoveTo(WeenieError.NoPhysicsObject);
@@ -1460,6 +1485,9 @@ public sealed class MoveToManager
///
public void CancelMoveTo(WeenieError error)
{
+ if (s_mvtoDiag && MovementTypeState != MovementType.Invalid)
+ System.Console.WriteLine($"[mvto] CancelMoveTo (real) wasType={MovementTypeState} err={error}");
+
_ = error; // retail: never read in the body (decomp §7c) — kept for parity/logging.
if (MovementTypeState == MovementType.Invalid) return;
diff --git a/src/AcDream.Core/Physics/MotionInterpreter.cs b/src/AcDream.Core/Physics/MotionInterpreter.cs
index 8639688c..e30a7dfa 100644
--- a/src/AcDream.Core/Physics/MotionInterpreter.cs
+++ b/src/AcDream.Core/Physics/MotionInterpreter.cs
@@ -2286,9 +2286,34 @@ public sealed class MotionInterpreter : IMotionDoneSink
/// Retail arg2 — MotionNode.context_id.
/// Retail arg3 — MotionNode.motion.
/// Retail arg4 — MotionNode.jump_error_code.
+ // #170 temp drain diag (ACDREAM_MVTO_DIAG): aggregate add_to_queue vs
+ // MotionDone counts + this interp's pending depth — the apples-to-apples of
+ // the retail cdb drain trace (retail: add==done). Strip after #170.
+ private static readonly bool s_drainDiag =
+ System.Environment.GetEnvironmentVariable("ACDREAM_MVTO_DIAG") == "1";
+ private static int s_addCount;
+ private static int s_doneCount;
+
public void AddToQueue(uint contextId, uint motion, uint jumpErrorCode)
{
_pendingMotions.AddLast(new MotionNode(contextId, motion, jumpErrorCode));
+ if (s_drainDiag)
+ {
+ s_addCount++;
+ // Dump this interp's queue CONTENTS whenever a backlog is present
+ // (depth >= 2 = something isn't draining) so we can see WHICH motion
+ // lingers and keeps MotionsPending() true (blocking the chase turn).
+ if (_pendingMotions.Count >= 2)
+ {
+ var sb = new System.Text.StringBuilder();
+ foreach (var n in _pendingMotions) { sb.Append("0x"); sb.Append(n.Motion.ToString("X8")); sb.Append(' '); }
+ System.Console.WriteLine(System.FormattableString.Invariant(
+ $"[drainq] depth={_pendingMotions.Count} lag={s_addCount - s_doneCount} q=[{sb}]"));
+ }
+ else if (s_addCount % 50 == 0)
+ System.Console.WriteLine(System.FormattableString.Invariant(
+ $"[drain] add={s_addCount} done={s_doneCount} lag={s_addCount - s_doneCount} depth={_pendingMotions.Count}"));
+ }
}
///
@@ -2336,7 +2361,10 @@ public sealed class MotionInterpreter : IMotionDoneSink
// the same node since nothing else can mutate the queue mid-call.
var head1 = _pendingMotions.First;
if (head1 is not null)
+ {
_pendingMotions.RemoveFirst();
+ if (s_drainDiag) s_doneCount++;
+ }
}
///