diff --git a/docs/research/2026-04-28-remote-moveto-pseudocode.md b/docs/research/2026-04-28-remote-moveto-pseudocode.md
new file mode 100644
index 0000000..19ec7c9
--- /dev/null
+++ b/docs/research/2026-04-28-remote-moveto-pseudocode.md
@@ -0,0 +1,285 @@
+# Phase L.1c — Remote MoveTo body-driver pseudocode
+
+**Date**: 2026-04-28
+**Goal**: Port the minimum viable subset of retail `MoveToManager` so the body
+position of server-controlled chasing creatures (movementType 6/7) tracks the
+server-supplied destination smoothly, instead of freezing at zero velocity
+between sparse `UpdatePosition` snaps.
+
+## Problem (root cause from systematic-debugging Phase 1)
+
+The 882a07c stabilizer holds `rm.Body.Velocity = 0` while `ServerMoveToActive`
+is true, on the principle "do not let `apply_current_movement` free-run with
+incomplete MoveTo state." The state IS incomplete: our parser at
+[`UpdateMotion.cs:280-290`](../../src/AcDream.Core.Net/Messages/UpdateMotion.cs)
+keeps only `speed`/`runRate`/flags from the 7-DWORD `MovementParameters`
+block and the `runRate` trailer, **discarding** `Origin (destination)`,
+`targetGuid` (type 6 only), `distance_to_object`, `min_distance`,
+`fail_distance`, `walk_run_threshhold`, and `desired_heading`.
+
+Symptoms (live log + user observation 2026-04-28):
+- **Disappearing**: body frozen at `Velocity=0` while RunForward animation
+ plays; next UpdatePosition teleports body to actual server pose. If the
+ teleport target is outside the visible window, observer sees disappear/reappear.
+- **Jitter**: when a stale UP-derived velocity exists, body extrapolates along
+ the OLD heading; meanwhile the server is steering the creature on a curve.
+ Each new UP snap-corrects → visible stutter.
+
+The fresh MoveTo packet stream (~1 Hz, seq 0x01FE→0x0204 in the live log) IS
+sending fresh target positions and headings each tick — we're throwing them
+away.
+
+## Retail behavior (named decomp + ACE port)
+
+Sources:
+- `docs/research/named-retail/acclient_2013_pseudo_c.txt` — citations below
+- `docs/research/named-retail/acclient.h` — struct definitions
+- `references/ACE/Source/ACE.Server/Physics/Animation/MoveToManager.cs`
+- `references/ACE/Source/ACE.Server/Physics/Animation/MovementParameters.cs`
+
+### Wire layout (`MovementParameters::UnPackNet` @ `0x0052ac50`, type 6/7)
+
+```
+[uint targetGuid] // type 6 only (MoveToObject)
+Origin: uint cellId // then 3 floats local x/y/z
+ float x, y, z // destination position
+MovementParameters (28 bytes, exact retail order):
+ uint flags // bitfield (see below)
+ float distance_to_object // arrival far-bound (ACE default 0.6)
+ float min_distance // arrival near-bound
+ float fail_distance // abort when starting→current >= this
+ float speed // base speed multiplier
+ float walk_run_threshhold // (sic, two h's) — wire default 15.0
+ float desired_heading // final orientation (radians or degrees)
+float runRate // CMotionInterp::my_run_rate copy
+```
+
+### MovementParameters bit-flags (declaration order, acclient.h:31423-31443)
+
+| Bit | Mask | Name | Meaning |
+|----:|---------|------|---------|
+| 0 | 0x00001 | can_walk | gait permission |
+| 1 | 0x00002 | can_run | gait permission (we already use this for `MoveToCanRun`) |
+| 2 | 0x00004 | can_sidestep | enables strafe path |
+| 3 | 0x00008 | can_walk_backwards | gait permission |
+| 4 | 0x00010 | can_charge | force HoldKey_Run |
+| 5 | 0x00020 | fail_walk | fail if only walk possible |
+| 6 | 0x00040 | use_final_heading | append final TurnToHeading after arrival |
+| 7 | 0x00080 | sticky | MoveToObject only — StickTo on completion |
+| 8 | 0x00100 | move_away | flee target |
+| 9 | 0x00200 | move_towards | chase target (chase creatures set this) |
+| 10 | 0x00400 | use_spheres | use cylinder distance vs straight-line |
+| 11 | 0x00800 | set_hold_key | apply HoldKeyToApply |
+| ... | ... | ... | (autonomous, modify_*_state, cancel_moveto, stop_completely, disable_jump) |
+
+### MoveToManager::HandleMoveToPosition (per-tick, `0x00529d80` lines 307187-307440)
+
+```
+if physics.motions_pending:
+ cancel any aux turn cmd (let the queued motion complete)
+else:
+ targetWorld = currentTargetPosition // last server-supplied destination
+ desiredHeading = atan2(targetWorld - body.position) + get_desired_heading(currentCmd)
+ headingDelta = normalize(desiredHeading - body.heading)
+ if |headingDelta| <= 20°: // retail tolerance
+ // ACE adds set_heading(target, true) here (server-tic-rate fudge)
+ cancel any aux turn cmd
+ else:
+ edi = (headingDelta < 180°) ? TurnLeft : TurnRight
+ if edi != auxCommand:
+ _DoMotion(edi) // -> CMotionInterp
+ auxCommand = edi
+dist = GetCurrentDistance()
+if CheckProgressMade(dist):
+ if !movingAway and dist <= min_distance: // arrived
+ popHeadNode(); _StopMotion(currentCmd); _StopMotion(auxCommand); BeginNextNode()
+ if movingAway and dist >= distance_to_object:
+ popHeadNode(); ...
+ if !movingAway and Position.distance(starting, current) >= fail_distance:
+ CancelMoveTo(0x3d) // YouChargedTooFar
+```
+
+### Key insight: MoveToManager does NOT touch the body directly
+
+Every motion start/stop is dispatched through `CMotionInterp::DoInterpretedMotion`
+(via `_DoMotion`/`_StopMotion`). The body's actual position evolves via the
+ordinary physics tick (`PhysicsBody::UpdatePhysicsInternal`). MoveToManager is
+purely a *planner* sitting above CMotionInterp, deciding *which command* (and
+which auxiliary turn) the body should be running at any given tick.
+
+## Acdream port — minimum viable subset
+
+The server re-emits MoveTo packets ~1 Hz with fresh destinations, so we can
+skip:
+- `MoveToObject_Internal` target-tracking (`HandleUpdateTarget`) — server does it
+- Sticky / `PositionManager::StickTo`
+- `CheckProgressMade` stall detection — server cancels the move
+- `fail_distance` / `WeenieError.YouChargedTooFar` — server-side concern
+- `WeenieObj::OnMoveComplete` callback
+- Pending-actions queue (only ever 1-2 nodes; we treat each MoveTo packet as
+ a fresh single-step plan)
+
+We DO need:
+1. **Parser**: extract the discarded fields into `ServerMotionState`.
+2. **Per-tick steer**: compute heading-to-destination, turn body orientation
+ toward it (snap when within ±20° per ACE's tic-rate fudge), then *allow*
+ `apply_current_movement` to run — which sets `Body.Velocity` from the
+ active RunForward cycle, oriented along the now-correct heading.
+3. **Arrival**: when `dist <= distance_to_object`, switch animation to Ready
+ and clear `ServerMoveToActive`. Server's next MoveTo packet will resume.
+
+## Pseudocode — acdream port
+
+### Parser change (`UpdateMotion.TryParseMoveToPayload`)
+
+```
+TryParseMoveToPayload(body, pos, mt, out parsed):
+ if mt == 6:
+ if rem < 4: return false
+ parsed.TargetGuid = ReadU32; pos += 4
+
+ if rem < 16: return false
+ parsed.OriginCellId = ReadU32; pos += 4
+ parsed.OriginX = ReadF32; pos += 4
+ parsed.OriginY = ReadF32; pos += 4
+ parsed.OriginZ = ReadF32; pos += 4
+
+ if rem < 28: return false
+ parsed.Flags = ReadU32; pos += 4
+ parsed.DistanceToObject = ReadF32; pos += 4
+ parsed.MinDistance = ReadF32; pos += 4
+ parsed.FailDistance = ReadF32; pos += 4
+ parsed.Speed = ReadF32; pos += 4
+ parsed.WalkRunThreshold = ReadF32; pos += 4
+ parsed.DesiredHeading = ReadF32; pos += 4
+
+ if rem < 4: return false
+ parsed.RunRate = ReadF32
+ return true
+```
+
+### Per-tick driver (new `RemoteMoveToDriver` in `AcDream.Core.Physics`)
+
+```
+DriveOneTick(rm, dt):
+ if not rm.HasMoveToDestination: return ApplyDefault
+
+ targetWorld = rm.MoveToDestinationWorld // pre-converted at packet time
+ bodyPos = rm.Body.Position
+
+ // Distance check first — arrival short-circuits before any heading work
+ dist = horizontalDistance(targetWorld, bodyPos)
+ if dist <= rm.MoveToMinDistance + 0.05 (epsilon for float wobble):
+ rm.HasMoveToDestination = false
+ // animation cycle moves to Ready via the existing
+ // ApplyServerControlledVelocityCycle path on next zero-velocity sample
+ rm.Body.Velocity = Vector3.Zero
+ return Arrived
+
+ // Heading compute (XY plane; Z untouched — server owns Z)
+ deltaXY = (targetWorld.XY - bodyPos.XY).Normalized
+ desiredHeading = atan2(deltaXY) // radians
+ currentHeading = QuaternionToYaw(rm.Body.Orientation)
+ headingDelta = wrapPi(desiredHeading - currentHeading)
+
+ // Snap orientation toward target — match ACE's set_heading(target, true)
+ // when within tolerance, otherwise rotate at retail-faithful turn rate.
+ const float tolerance = 20° (in radians)
+ if |headingDelta| <= tolerance:
+ rm.Body.Orientation = QuaternionFromYaw(desiredHeading)
+ else:
+ // retail TurnSpeed default ≈ π/2 rad/s for monsters; clamp by dt
+ float maxStep = TurnRateRadPerSec * dt
+ float step = clamp(headingDelta, -maxStep, +maxStep)
+ rm.Body.Orientation = QuaternionFromYaw(currentHeading + step)
+
+ // Allow apply_current_movement to set Velocity from RunForward cycle.
+ // The cycle was already seeded by PlanMoveToStart at packet receipt
+ // and is being played by the AnimationSequencer. CMotionInterp's
+ // apply_current_movement reads InterpretedState.ForwardCommand and
+ // sets Body.Velocity = (forward axis of orientation) * RunAnimSpeed * speedMod.
+ return DriveActive // caller now invokes apply_current_movement
+```
+
+### Integration in `GameWindow.OnUpdateMotion` (movementType 6/7 branch)
+
+```
+on receipt of MoveTo packet:
+ // existing code already seeds the animation cycle via PlanMoveToStart
+ // NEW: store world-converted destination + thresholds on rmState
+ lbX = (originCellId >> 24) & 0xFF
+ lbY = (originCellId >> 16) & 0xFF
+ origin = ((lbX - liveCenterX) * 192, (lbY - liveCenterY) * 192, 0)
+ rmState.MoveToDestinationWorld = (originX, originY, originZ) + origin
+ rmState.MoveToMinDistance = parsed.MinDistance
+ rmState.MoveToDistanceToObject = parsed.DistanceToObject
+ rmState.HasMoveToDestination = true
+ // ServerMoveToActive remains set; existing
+```
+
+### Integration in per-tick remote update (`GameWindow.cs` ~line 5045)
+
+```
+// Replace the current Velocity = Zero hold with:
+else if (!IsPlayerGuid(serverGuid) && rm.ServerMoveToActive && rm.HasMoveToDestination)
+{
+ var driveResult = RemoteMoveToDriver.DriveOneTick(rm, dt);
+ if driveResult == Arrived:
+ // signal cycle update to Ready via existing path
+ ApplyServerControlledVelocityCycle(serverGuid, ae, rm, Vector3.Zero);
+ else:
+ rm.Body.TransientState |= Contact | OnWalkable | Active
+ rm.Motion.apply_current_movement(cancelMoveTo: false, allowJump: false);
+}
+else if (!IsPlayerGuid(serverGuid) && rm.ServerMoveToActive)
+{
+ // No destination yet (very early frame, packet hasn't fully landed)
+ rm.Body.Velocity = Vector3.Zero;
+}
+else
+{
+ rm.Motion.apply_current_movement(cancelMoveTo: false, allowJump: false);
+}
+```
+
+## Conformance test cases
+
+1. **Parser round-trip — type 7 (MoveToPosition)**
+ - Synthesize a 68-byte body with known origin + 7 params + runRate.
+ - Assert all 9 new fields decode correctly.
+
+2. **Parser round-trip — type 6 (MoveToObject)**
+ - Synthesize a 72-byte body with target guid + origin + params + runRate.
+ - Assert TargetGuid populated and shifts subsequent fields by 4 bytes.
+
+3. **DriveOneTick — heading snap within tolerance**
+ - body at (0,0,0) facing east, destination (10,0,0).
+ - DesiredHeading=0; current=0; |delta|=0 ≤ 20° → snap.
+ - assert orientation unchanged (already correct).
+
+4. **DriveOneTick — heading turn beyond tolerance**
+ - body at (0,0,0) facing east, destination (0,10,0).
+ - desiredHeading=π/2; current=0; |delta|=π/2 > 20°.
+ - dt=0.1s, TurnRate=π/2 → step = π/4 toward target.
+ - assert orientation rotated by π/4 (not full snap).
+
+5. **DriveOneTick — arrival**
+ - body at (0,0,0), destination (0.4,0,0), MinDistance=0.6.
+ - assert HasMoveToDestination cleared and Velocity zeroed.
+
+6. **Bit-flag mapping** (already partially tested via `MoveToCanRun`)
+ - assert flag 0x00200 (move_towards) is detected as `MoveTowards=true`.
+
+## Out of scope (future Phase L.1d if needed)
+
+- Sticky / StickTo for MoveToObject completion
+- `use_final_heading` (post-arrival turn-to-heading)
+- `fail_distance` early-cancel (server already does this; we just don't flag it)
+- `CheckProgressMade` stall detector
+- Strafe / move_away / move_towards-and-away combo (`towards_and_away` helper)
+- Sphere-cylinder distance (`use_spheres` bit)
+- `MoveToObject` target-guid resolution — currently we only honor the Origin,
+ which works because the server re-emits with refreshed Origin each tick.
+ If the target is moving fast and the server's emit cadence falls behind,
+ we'd see lag; a future enhancement is to look up the target entity by
+ guid and use its current world position when fresher than Origin.
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index 353f8b4..aae7478 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -226,11 +226,50 @@ public sealed class GameWindow : IDisposable
///
/// True while a server MoveToObject/MoveToPosition packet is the
/// active locomotion source. Retail runs these through MoveToManager
- /// and CMotionInterp using the packet's runRate; until we port the
- /// full target solver, use this only to protect packet-derived
- /// animation speed from velocity-cycle clobbering.
+ /// and CMotionInterp; the per-tick remote driver consults this to
+ /// decide whether to feed body steering through
+ /// instead of
+ /// the InterpretedMotionState path.
///
public bool ServerMoveToActive;
+
+ ///
+ /// True once a MoveTo packet's full path payload (Origin + thresholds)
+ /// has been parsed and the world-converted destination is stored on
+ /// . Cleared on arrival or when
+ /// the next non-MoveTo UpdateMotion replaces the locomotion source.
+ /// Phase L.1c (2026-04-28).
+ ///
+ public bool HasMoveToDestination;
+
+ ///
+ /// World-space destination from the most recent MoveTo packet's
+ /// Origin field, converted via the same landblock-grid
+ /// arithmetic OnLivePositionUpdated uses.
+ ///
+ public System.Numerics.Vector3 MoveToDestinationWorld;
+
+ ///
+ /// min_distance from the MoveTo packet's MovementParameters.
+ /// Used by as
+ /// the chase-arrival threshold per retail
+ /// MoveToManager::HandleMoveToPosition.
+ ///
+ public float MoveToMinDistance;
+
+ ///
+ /// distance_to_object from the MoveTo packet. Reserved for
+ /// the flee branch (move_away); chase uses
+ /// .
+ ///
+ public float MoveToDistanceToObject;
+
+ ///
+ /// True if MovementParameters bit 9 (move_towards, mask
+ /// 0x200) is set on the active packet — i.e. this is a
+ /// chase. False = flee (move_away) or static target.
+ ///
+ public bool MoveToMoveTowards;
///
/// Legacy field — no longer used for slerp (retail hard-snaps
/// per FUN_00514b90 set_frame). Kept to avoid churn.
@@ -2454,6 +2493,37 @@ public sealed class GameWindow : IDisposable
{
remoteMot.ServerMoveToActive = update.MotionState.IsServerControlledMoveTo;
+ // Phase L.1c (2026-04-28): capture the full MoveTo path
+ // payload so the per-tick remote driver can steer the
+ // body toward Origin instead of holding velocity at zero
+ // between sparse UpdatePosition snaps. Retail
+ // MoveToManager::MoveToPosition stores the same fields
+ // (acclient_2013_pseudo_c.txt:307521-307593).
+ 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;
+ }
+ else if (!update.MotionState.IsServerControlledMoveTo)
+ {
+ // Cycle changed off MoveTo — clear stale destination
+ // so the per-tick driver doesn't keep steering after
+ // the server has switched us back to interpreted
+ // motion.
+ remoteMot.HasMoveToDestination = false;
+ }
+
// Forward axis (Ready / WalkForward / RunForward / WalkBackward).
remoteMot.Motion.DoInterpretedMotion(
fullMotion, speedMod, modifyInterpretedState: true);
@@ -5042,13 +5112,53 @@ 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.
+ var driveResult = AcDream.Core.Physics.RemoteMoveToDriver
+ .Drive(
+ rm.Body.Position,
+ rm.Body.Orientation,
+ rm.MoveToDestinationWorld,
+ rm.MoveToMinDistance,
+ (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);
+ }
+ }
else if (!IsPlayerGuid(serverGuid) && rm.ServerMoveToActive)
{
- // We only parse enough of MoveTo to recover retail
- // animation speed. Do not let apply_current_movement
- // extrapolate position from an incomplete target
- // solver; hold until the next UpdatePosition-derived
- // velocity arrives.
+ // 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
diff --git a/src/AcDream.Core.Net/Messages/CreateObject.cs b/src/AcDream.Core.Net/Messages/CreateObject.cs
index 847f1c2..39b30cd 100644
--- a/src/AcDream.Core.Net/Messages/CreateObject.cs
+++ b/src/AcDream.Core.Net/Messages/CreateObject.cs
@@ -143,7 +143,8 @@ public static class CreateObject
byte MovementType = 0,
uint? MoveToParameters = null,
float? MoveToSpeed = null,
- float? MoveToRunRate = null)
+ float? MoveToRunRate = null,
+ MoveToPathData? MoveToPath = null)
{
///
/// ACE/retail movement types 6 and 7 are server-controlled
@@ -155,8 +156,43 @@ public static class CreateObject
public bool MoveToCanRun => !MoveToParameters.HasValue
|| (MoveToParameters.Value & 0x2u) != 0;
+
+ ///
+ /// MovementParameters bit 9 (mask 0x200) — set when the creature is
+ /// chasing its target. Cross-checked against acclient.h:31423-31443
+ /// (named retail) + ACE MovementParamFlags.MoveTowards.
+ ///
+ public bool MoveTowards => MoveToParameters.HasValue
+ && (MoveToParameters.Value & 0x200u) != 0;
}
+ ///
+ /// Path-control payload of a server-controlled MoveTo packet (movementType 6 or 7).
+ /// Wire layout per MovementParameters::UnPackNet @ 0x0052ac50
+ /// + the leading Origin + optional target guid for type 6:
+ ///
+ /// - type 6 (MoveToObject) only: u32 TargetGuid
+ /// - Origin: u32 cellId, then 3 floats (local x/y/z within the landblock)
+ /// - MovementParameters (28 bytes, exact retail order):
+ /// u32 flags, f32 distance_to_object, f32 min_distance,
+ /// f32 fail_distance, f32 speed, f32 walk_run_threshhold,
+ /// f32 desired_heading
+ ///
+ /// (The trailing runRate float is captured separately on
+ /// .)
+ ///
+ public readonly record struct MoveToPathData(
+ uint? TargetGuid,
+ uint OriginCellId,
+ float OriginX,
+ float OriginY,
+ float OriginZ,
+ float DistanceToObject,
+ float MinDistance,
+ float FailDistance,
+ float WalkRunThreshold,
+ float DesiredHeading);
+
///
/// One entry in the InterpretedMotionState's Commands list (MotionItem).
/// The server packs 0..many of these per broadcast: emotes, attacks,
diff --git a/src/AcDream.Core.Net/Messages/UpdateMotion.cs b/src/AcDream.Core.Net/Messages/UpdateMotion.cs
index 6cc76cc..8756281 100644
--- a/src/AcDream.Core.Net/Messages/UpdateMotion.cs
+++ b/src/AcDream.Core.Net/Messages/UpdateMotion.cs
@@ -130,6 +130,7 @@ public static class UpdateMotion
uint? moveToParameters = null;
float? moveToSpeed = null;
float? moveToRunRate = null;
+ CreateObject.MoveToPathData? moveToPath = null;
List? commands = null;
if (movementType == 0)
@@ -232,7 +233,8 @@ public static class UpdateMotion
movementType,
out moveToParameters,
out moveToSpeed,
- out moveToRunRate);
+ out moveToRunRate,
+ out moveToPath);
}
return new Parsed(guid, new CreateObject.ServerMotionState(
@@ -241,7 +243,8 @@ public static class UpdateMotion
movementType,
moveToParameters,
moveToSpeed,
- moveToRunRate));
+ moveToRunRate,
+ moveToPath));
}
catch
{
@@ -255,11 +258,13 @@ public static class UpdateMotion
byte movementType,
out uint? movementParameters,
out float? speed,
- out float? runRate)
+ out float? runRate,
+ out CreateObject.MoveToPathData? path)
{
movementParameters = null;
speed = null;
runRate = null;
+ path = null;
// Retail MovementManager::PerformMovement (0x00524440) consumes
// MoveToObject/MoveToPosition as:
@@ -268,25 +273,60 @@ public static class UpdateMotion
// MovementParameters::UnPackNet (0x0052AC50): flags, distance,
// min, fail, speed, walk/run threshold, desired heading
// f32 runRate copied into CMotionInterp::my_run_rate.
+ //
+ // Phase L.1c (2026-04-28): the full path payload is now retained on
+ // so the per-tick remote
+ // body driver can steer toward Origin instead of holding velocity at
+ // zero between sparse UpdatePosition snaps. The 882a07c stabilizer
+ // was deliberately conservative because we only had speed+runRate;
+ // with the rest of the packet captured, the body solver has full
+ // path data and can run faithfully.
+ uint? targetGuid = null;
if (movementType == 6)
{
if (body.Length - pos < 4) return false;
- pos += 4; // target guid
+ targetGuid = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
+ pos += 4;
}
if (body.Length - pos < 16 + 28 + 4) return false;
- pos += 16; // Origin
+
+ uint originCellId = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
+ pos += 4;
+ float originX = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
+ pos += 4;
+ float originY = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
+ pos += 4;
+ float originZ = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
+ pos += 4;
movementParameters = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
pos += 4;
- pos += 4; // distanceToObject
- pos += 4; // minDistance
- pos += 4; // failDistance
+ float distanceToObject = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
+ pos += 4;
+ float minDistance = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
+ pos += 4;
+ float failDistance = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
+ pos += 4;
speed = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
pos += 4;
- pos += 4; // walkRunThreshold
- pos += 4; // desiredHeading
+ float walkRunThreshold = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
+ pos += 4;
+ float desiredHeading = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
+ pos += 4;
runRate = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
+
+ path = new CreateObject.MoveToPathData(
+ targetGuid,
+ originCellId,
+ originX,
+ originY,
+ originZ,
+ distanceToObject,
+ minDistance,
+ failDistance,
+ walkRunThreshold,
+ desiredHeading);
return true;
}
}
diff --git a/src/AcDream.Core/Physics/RemoteMoveToDriver.cs b/src/AcDream.Core/Physics/RemoteMoveToDriver.cs
new file mode 100644
index 0000000..0b6a675
--- /dev/null
+++ b/src/AcDream.Core/Physics/RemoteMoveToDriver.cs
@@ -0,0 +1,204 @@
+using System;
+using System.Numerics;
+
+namespace AcDream.Core.Physics;
+
+///
+/// Per-tick steering for server-controlled remote creatures while a
+/// MoveToObject (movementType 6) or MoveToPosition (movementType 7) packet
+/// is the active locomotion source.
+///
+///
+/// Replaces the 882a07c-era "hold body Velocity at zero during MoveTo"
+/// stabilizer. With the full MoveTo path payload now captured on
+/// ,
+/// 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.
+///
+///
+///
+/// Retail references:
+///
+/// -
+/// MoveToManager::HandleMoveToPosition (0x00529d80) — the
+/// per-tick driver. Computes heading-to-target, fires an aux
+/// TurnLeft/TurnRight command when |delta| > 20°, snaps
+/// orientation when within tolerance, and tests arrival via
+/// dist <= min_distance (chase) or
+/// dist >= distance_to_object (flee).
+///
+/// -
+/// MoveToManager::_DoMotion / _StopMotion route turn
+/// commands through CMotionInterp::DoInterpretedMotion — i.e.
+/// MoveToManager itself does NOT touch the body. The body's actual
+/// velocity comes from CMotionInterp::apply_current_movement
+/// reading InterpretedState.ForwardCommand = RunForward and
+/// emitting velocity.Y = RunAnimSpeed × speedMod, transformed by
+/// the body's orientation.
+///
+///
+///
+///
+///
+/// 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 set_heading(true) snap-on-aligned fudge), and
+/// arrival detection via min_distance.
+///
+///
+///
+/// ACE divergence: ACE swaps the chase/flee arrival predicates
+/// (dist <= DistanceToObject vs retail's dist <= MinDistance).
+/// We follow retail.
+///
+///
+public static class RemoteMoveToDriver
+{
+ ///
+ /// Heading tolerance below which we snap orientation directly to the
+ /// target heading (ACE's set_heading(target, true)
+ /// server-tic-rate fudge). Above tolerance we rotate at
+ /// . Retail value (line 307251 of
+ /// acclient_2013_pseudo_c.txt) is 20°.
+ ///
+ public const float HeadingSnapToleranceRad = 20.0f * MathF.PI / 180.0f;
+
+ ///
+ /// Default angular rate for in-motion heading correction when delta
+ /// exceeds . Picked to match
+ /// ACE's TurnSpeed default of π/2 rad/s for monsters;
+ /// when the per-creature value differs, the future port can wire it
+ /// in via the TurnSpeed field on InterpretedMotionState.
+ ///
+ public const float TurnRateRadPerSec = MathF.PI / 2.0f;
+
+ ///
+ /// Float-comparison slack for the arrival predicate. With
+ /// min_distance == 0 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.
+ ///
+ public const float ArrivalEpsilon = 0.05f;
+
+ public enum DriveResult
+ {
+ /// Within arrival window — caller should zero velocity.
+ Arrived,
+ /// Steering active — caller should let
+ /// apply_current_movement set body velocity from the cycle.
+ Steering,
+ }
+
+ ///
+ /// Steer body orientation toward
+ /// and report whether the body has arrived (within
+ /// ) or should keep running. Pure
+ /// function — emits the updated orientation via
+ /// (the input is not mutated; the
+ /// caller assigns the new value back to its body).
+ ///
+ public static DriveResult Drive(
+ Vector3 bodyPosition,
+ Quaternion bodyOrientation,
+ Vector3 destinationWorld,
+ float minDistance,
+ 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
+ // (chase: dist ≤ min_distance; flee branch is unused here, but
+ // we honor the moveTowards flag for symmetry).
+ if (moveTowards && dist <= minDistance + 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;
+ }
+
+ ///
+ /// Convert a landblock-local Origin from a MoveTo packet
+ /// ()
+ /// into acdream's render world space using the same arithmetic as
+ /// OnLivePositionUpdated: shift by the landblock-grid offset
+ /// from the live-mode center.
+ ///
+ 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);
+ }
+
+ /// Wrap an angle in radians to [-π, π].
+ 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;
+ }
+}
diff --git a/tests/AcDream.Core.Net.Tests/Messages/UpdateMotionTests.cs b/tests/AcDream.Core.Net.Tests/Messages/UpdateMotionTests.cs
index ad0f01a..da042f0 100644
--- a/tests/AcDream.Core.Net.Tests/Messages/UpdateMotionTests.cs
+++ b/tests/AcDream.Core.Net.Tests/Messages/UpdateMotionTests.cs
@@ -251,5 +251,69 @@ public class UpdateMotionTests
Assert.Equal(1.25f, result.Value.MotionState.MoveToSpeed);
Assert.Equal(1.5f, result.Value.MotionState.MoveToRunRate);
Assert.True(result.Value.MotionState.MoveToCanRun);
+ Assert.True(result.Value.MotionState.MoveTowards);
+
+ // Phase L.1c (2026-04-28): full path payload retained.
+ Assert.NotNull(result.Value.MotionState.MoveToPath);
+ var path = result.Value.MotionState.MoveToPath!.Value;
+ Assert.Null(path.TargetGuid);
+ Assert.Equal(0xA8B4000Eu, path.OriginCellId);
+ Assert.Equal(10f, path.OriginX);
+ Assert.Equal(20f, path.OriginY);
+ Assert.Equal(30f, path.OriginZ);
+ Assert.Equal(0.6f, path.DistanceToObject);
+ Assert.Equal(0.0f, path.MinDistance);
+ Assert.Equal(float.MaxValue, path.FailDistance);
+ Assert.Equal(15.0f, path.WalkRunThreshold);
+ Assert.Equal(90.0f, path.DesiredHeading);
+ }
+
+ [Fact]
+ public void ParsesMoveToObjectTargetGuidAndOrigin()
+ {
+ // Type 6 (MoveToObject) prepends a u32 target guid before the
+ // standard Origin + MovementParameters + runRate payload.
+ // Body size: 20 (header) + 4 (guid) + 16 (origin) + 28 (params) + 4 (runRate) = 72.
+ var body = new byte[20 + 4 + 16 + 28 + 4];
+ int p = 0;
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xF74Cu); p += 4;
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x80004321u); p += 4;
+ BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2;
+ p += 6; // MovementData header padding
+
+ body[p++] = 6; // MoveToObject
+ body[p++] = 0;
+ BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x003D); p += 2;
+
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x80001234u); p += 4; // target guid
+
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xA8B4000Eu); p += 4; // cell
+ BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 5f); p += 4; // origin x
+ BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 6f); p += 4; // origin y
+ BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 7f); p += 4; // origin z
+
+ const uint flags = 0x1u | 0x2u | 0x200u; // can_walk | can_run | move_towards
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), flags); p += 4;
+ BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 0.6f); p += 4;
+ BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 0.0f); p += 4;
+ BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), float.MaxValue); p += 4;
+ BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 1.0f); p += 4;
+ BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 15.0f); p += 4;
+ BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 1.57f); p += 4;
+ BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 1.25f); p += 4; // runRate
+
+ var result = UpdateMotion.TryParse(body);
+
+ Assert.NotNull(result);
+ Assert.Equal((byte)6, result!.Value.MotionState.MovementType);
+ Assert.True(result.Value.MotionState.IsServerControlledMoveTo);
+ Assert.NotNull(result.Value.MotionState.MoveToPath);
+ var path = result.Value.MotionState.MoveToPath!.Value;
+ Assert.Equal(0x80001234u, path.TargetGuid);
+ Assert.Equal(0xA8B4000Eu, path.OriginCellId);
+ Assert.Equal(5f, path.OriginX);
+ Assert.Equal(6f, path.OriginY);
+ Assert.Equal(7f, path.OriginZ);
+ Assert.Equal(1.25f, result.Value.MotionState.MoveToRunRate);
}
}
diff --git a/tests/AcDream.Core.Tests/Physics/RemoteMoveToDriverTests.cs b/tests/AcDream.Core.Tests/Physics/RemoteMoveToDriverTests.cs
new file mode 100644
index 0000000..7624734
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Physics/RemoteMoveToDriverTests.cs
@@ -0,0 +1,159 @@
+using System;
+using System.Numerics;
+using AcDream.Core.Physics;
+using Xunit;
+
+namespace AcDream.Core.Tests.Physics;
+
+///
+/// Phase L.1c (2026-04-28). Covers — the
+/// per-tick steering port of retail
+/// MoveToManager::HandleMoveToPosition for server-controlled remote
+/// creatures.
+///
+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, dt: 0.016f, moveTowards: true,
+ out var newOrient);
+
+ Assert.Equal(RemoteMoveToDriver.DriveResult.Arrived, result);
+ Assert.Equal(bodyRot, newOrient); // orientation untouched
+ }
+
+ [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, 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, 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, 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, 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, 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 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);
+ }
+}