acdream/docs/research/2026-07-03-r4-moveto/r4-ace-moveto.md
Erik 988304e13b docs(R4-V-1): MoveToManager research base — decomp extraction + ACE cross-ref + port work-list
Workflow-produced R4 research (3 docs, 2,541 lines):
- r4-moveto-decomp.md: all 33 MoveToManager members verbatim
  (0x00529010-0x0052a987) + the MovementManager type-6/7/8/9 relay +
  MovementParameters::get_command. HandleUpdateTarget CONFIRMED on
  MoveToManager (0x0052a7d0 — closes the R3 negative result; object
  moves are DEFERRED until the first target-update callback). Walk-vs-
  run = the CanCharge rule exactly (can_charge OR can_run && dist-gap >
  walk_run_threshhold, riding hold_key_to_apply into DoInterpretedMotion
  — MoveToManager never calls set_hold_run). Sticky handoff located
  (empty queue + 0x80 → StickTo). fail_progress_count is WRITE-ONLY in
  retail (no give-up threshold — do not invent one). 8-class BN
  artifact ledger.
- r4-ace-moveto.md: 16 flagged ACE-isms, all retail-verified — incl. a
  stale-member read (MoveToPosition checks the PREVIOUS move's
  UseFinalHeading), a field transposition (InitializeLocalVars zeroes
  DistanceToObject where retail zeroes the flags word), a dropped
  BeginNextNode (ACE turns start one tick late), a UseTime gate
  inversion, and the canceling CanCharge default+fast-path pair.
  Blast radius: acdream has THREE independent approximations of this
  one mechanism (DriveServerAutoWalk / RemoteMoveToDriver /
  ServerControlledLocomotion) + an outdated chase/flee claim in
  RemoteMoveToDriver's class doc.
- r4-port-plan.md: 7 pins (P1 blocker: retail interrupts current
  movement on EVERY UM but ACE sends a companion mt-0 echo after each
  MoveTo — needs a discriminator pin before V5; P3: heading_diff's
  TurnLeft mirror needs one Ghidra decompile of 0x00528fb0; P4:
  type-6/8 moves need a minimal TargetTracker adapter until R5),
  17 gaps M1-M17, commits V0-V6. Retires AD-8/AD-9/AP-8/AP-9 + TS-36.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 10:31:12 +02:00

50 KiB
Raw Blame History

R4 ACE cross-reference — MoveToManager / MovementParameters

Purpose: ACE-side oracle for the R4 MoveToManager port. Per-method transcription of references/ACE/Source/ACE.Server/Physics/Managers/MoveToManager.cs (874 lines) + references/ACE/Source/ACE.Server/Physics/Animation/MovementParameters.cs, call-site map, ACE-ism flags (each spot-checked against the named retail decomp where noted), and the acdream R4 blast-radius inventory.

Note on paths: the ACE reference lives in the MAIN repo tree (C:/Users/erikn/source/repos/acdream/references/ACE/...), NOT in the worktree — the worktree's references/ only contains WorldBuilder. ACE's file is Physics/Managers/MoveToManager.cs (namespace still says Physics.Animation — ACE moved the file without renaming the namespace).

Retail anchor table (named pseudo-C, docs/research/named-retail/acclient_2013_pseudo_c.txt):

Function Address pseudo-C line
MovementParameters::MovementParameters (ctor) 0x00524380 300510
heading_greater (free fn) 0x00528f60 306281
heading_diff (free fn) 0x00528fb0 306327
MoveToManager::_DoMotion 0x00529010 306351
MoveToManager::_StopMotion 0x00529080 306368
MoveToManager::CheckProgressMade 0x005290f0 306385
MoveToManager::GetCurrentDistance 0x005291b0 306435
MoveToManager::is_moving_to 0x00529220 306464
MoveToManager::InitializeLocalVariables 0x00529250 306490
MoveToManager::RemovePendingActionsHead 0x00529380 306538
MoveToManager::MoveToManager (ctor) 0x005293b0 306554
MoveToManager::CleanUp 0x005295c0 306710
MoveToManager::CleanUpAndCallWeenie 0x00529650 306740
MoveToManager::MoveToObject 0x00529680 306756
MoveToManager::TurnToObject 0x005297d0 306820
MoveToManager::CancelMoveTo 0x00529930 306886
MoveToManager::BeginMoveForward 0x00529a00 306957
MoveToManager::BeginTurnToHeading 0x00529b90 307046
MoveToManager::BeginNextNode 0x00529cb0 307123
MoveToManager::HitGround 0x00529d70 307175
MoveToManager::HandleMoveToPosition 0x00529d80 307187
MoveToManager::HandleTurnToHeading 0x0052a0c0 307442
MoveToManager::MoveToPosition 0x0052a240 307521
MoveToManager::MoveToObject_Internal 0x0052a400 307597
MoveToManager::TurnToObject_Internal 0x0052a550 307667
MoveToManager::TurnToHeading 0x0052a630 307706
MoveToManager::UseTime 0x0052a780 307776
MoveToManager::HandleUpdateTarget 0x0052a7d0 307802
MoveToManager::PerformMovement 0x0052a900 307871
MovementParameters::towards_and_away 0x0052a9a0 307917
MovementParameters::get_command 0x0052aa00 307946
MovementParameters::get_desired_heading 0x0052aad0 308016
MovementParameters::Pack / UnPack / UnPackNet 0x0052ab20 / 0x0052abc0 / 0x0052ac50 308037 / 308078 / 308118

Retail struct layout (verbatim PDB, acclient.h:31473-31497): movement_type, sought_position, current_target_position, starting_position, movement_params, previous_heading, previous_distance, previous_distance_time (long double), original_distance, original_distance_time, fail_progress_count, sought_object_id, top_level_object_id, sought_object_radius, sought_object_height, current_command, aux_command, moving_away, initialized, pending_actions (DLList<MovementNode>), physics_obj, weenie_obj. MovementNode : DLListData { MovementTypes::Type type; float heading; } (acclient.h:57702).


1. ACE MoveToManager — state

MoveToManager.cs:13-35:

MovementType MovementType            // enum: Invalid=0 ... MoveToObject=6, MoveToPosition=7, TurnToObject=8, TurnToHeading=9
Position SoughtPosition              // MoveToObject: interpolated target pos; also carries heading for TurnTo*
Position CurrentTargetPosition       // raw target position (distance measured against THIS)
Position StartingPosition            // for FailDistance check
MovementParameters MovementParams
float PreviousHeading                // NOTE: BeginTurnToHeading stores a heading DIFF here, HandleTurnToHeading stores headings (retail quirk, faithful)
float PreviousDistance; double PreviousDistanceTime
float OriginalDistance; double OriginalDistanceTime
int FailProgressCount                // incremented, NEVER read (see §5 negative results — retail-faithful)
uint SoughtObjectID, TopLevelObjectID
float SoughtObjectRadius, SoughtObjectHeight
uint CurrentCommand                  // active forward/turn motion (full 32-bit command)
uint AuxCommand                      // in-motion heading-correction turn command
bool MovingAway, Initialized
List<MovementNode> PendingActions    // MovementNode { MovementType Type; float Heading }
PhysicsObj PhysicsObj; WeenieObject WeenieObj
bool AlwaysTurn                      // ACE-ONLY (no retail counterpart) — see flag A7

MotionCommand constants used: WalkForward = 0x45000005, WalkBackwards = 0x45000006, TurnRight = 0x6500000D, TurnLeft = 0x6500000E, RunForward = 0x44000007. PhysicsGlobals.EPSILON = 0.0002f (retail literal 0.000199999995f everywhere in this family).

Init / InitializeLocalVars (:57-89)

Init(): MovementParams = new MovementParameters(); PendingActions = new List<MovementNode>();
InitializeLocalVars():
    MovementType = Invalid
    MovementParams.DistanceToObject = 0          // ⚠ flag A2 — retail zeroes the FLAGS word here, not distance_to_object
    MovementParams.ContextID = 0
    PreviousDistanceTime = OriginalDistanceTime = now
    PreviousHeading = 0
    FailProgressCount = 0; CurrentCommand = 0; AuxCommand = 0; MovingAway = false; Initialized = false
    SoughtPosition = new Position(); CurrentTargetPosition = new Position()
    SoughtObjectID = TopLevelObjectID = 0; SoughtObjectRadius = SoughtObjectHeight = 0
    // ⚠ flag A3 — retail ALSO sets previous_distance = original_distance = FLT_MAX here (0052926c/0052928a); ACE omits both

PerformMovement(MovementStruct mvs) (:91-112) — retail 0x0052a900 ✓ matches

CancelMoveTo(WeenieError.ActionCancelled)        // 0x36 — retail same constant
PhysicsObj.unstick_from_object()                 // ⚠ no null check; retail also derefs unconditionally here
switch (mvs.Type):
    MoveToObject   → MoveToObject(mvs.ObjectId, mvs.TopLevelId, mvs.Radius, mvs.Height, mvs.Params)
    MoveToPosition → MoveToPosition(mvs.Position, mvs.Params)
    TurnToObject   → TurnToObject(mvs.ObjectId, mvs.TopLevelId, mvs.Params)
    TurnToHeading  → TurnToHeading(mvs.Params)
return WeenieError.None                          // retail returns 0 unconditionally too

MoveToObject(objectID, topLevelID, radius, height, params) (:114-139) — retail 0x00529680 ✓

if (PhysicsObj == null) return                   // retail: null branch still runs StopCompletely-if-nonnull tail (no-op)
PhysicsObj.StopCompletely(false)
StartingPosition = copy(PhysicsObj.Position)
SoughtObjectID = objectID; SoughtObjectRadius = radius; SoughtObjectHeight = height
MovementType = MoveToObject; TopLevelObjectID = topLevelID
MovementParams = copy(movementParams)            // retail: full field-by-field copy incl flags; Sticky NOT unset here
Initialized = false
if (PhysicsObj.ID != topLevelID):
    PhysicsObj.set_target(0, TopLevelObjectID, 0.5f, 0.0)   // contextID=0, radius=0.5, quantum=0 — retail identical constants
    return
CleanUp(); PhysicsObj.StopCompletely(false)      // self-target degenerate path

No nodes are queued here — the chain continues via TargetManager voyeur callback → HandleUpdateTargetMoveToObject_Internal.

MoveToObject_Internal(targetPosition, interpolatedPosition) (:141-182) — retail 0x0052a400 ✓

if (PhysicsObj == null) { CancelMoveTo(NoPhysicsObject=8); return }
SoughtPosition = copy(interpolatedPosition); CurrentTargetPosition = copy(targetPosition)
iHeading = PhysicsObj.Position.heading(interpolatedPosition)   // note: heading measured to INTERPOLATED pos
heading = iHeading - PhysicsObj.get_heading()
dist = GetCurrentDistance()
if (|heading| < EPSILON) heading = 0; if (heading < -EPSILON) heading += 360
MovementParams.get_command(dist, heading, ref motionID, ref holdKey, ref moveAway)  // on the STORED member — retail 0052a4d6 same
if (motionID != 0): AddTurnToHeadingNode(iHeading); AddMoveToPositionNode()
if (MovementParams.UseFinalHeading):             // member read is CORRECT here (params stored by MoveToObject already)
    dHeading = iHeading + MovementParams.DesiredHeading; if (dHeading >= 360) dHeading -= 360
    AddTurnToHeadingNode(dHeading)
Initialized = true
BeginNextNode()

MoveToPosition(position, params) (:184-228) — retail 0x0052a240 — ⚠ flag A1

if (PhysicsObj == null) return
PhysicsObj.StopCompletely(false)
CurrentTargetPosition = copy(position); SoughtObjectRadius = 0
distance = GetCurrentDistance()
headingDiff = Position.heading(position) - get_heading(); epsilon-normalize as above
movementParams.get_command(distance, headingDiff, ref command, ref holdKey, ref moveAway)  // on the ARGUMENT — retail 0052a304 same
if (command != 0): AddTurnToHeadingNode(heading(position)); AddMoveToPositionNode()
if (MovementParams.UseFinalHeading)              // ⚠ A1: reads the STALE MEMBER (old params) — retail 0052a33b reads
    AddTurnToHeadingNode(movementParams.DesiredHeading)   //     the ARGUMENT's flag byte (arg3->__inner0 & 0x40)
SoughtPosition = copy(position); StartingPosition = copy(PhysicsObj.Position)
MovementType = MoveToPosition
MovementParams = copy(movementParams); MovementParams.Sticky = false   // retail: full copy then __inner0 &= 0xffffff7f ✓
BeginNextNode()

TurnToObject(objectID, topLevelID, params) (:230-259) — retail 0x005297d0 ✓

if (PhysicsObj == null) { MovementParams.ContextID = movementParams.ContextID; return }
if (movementParams.StopCompletely) PhysicsObj.StopCompletely(false)   // retail gates on flag byte2 & 1 (0x10000) ✓
MovementType = TurnToObject; SoughtObjectID = objectID
CurrentTargetPosition.Frame.set_heading(movementParams.DesiredHeading) // retail 0052986c writes current_target_position ✓
TopLevelObjectID = topLevelID
MovementParams = copy(movementParams)            // retail: full copy; Sticky NOT unset (unlike TurnToHeading)
if (PhysicsObj.ID != topLevelID):
    Initialized = false
    PhysicsObj.set_target(0, topLevelID, 0.5f, 0.0)
    return
CleanUp(); PhysicsObj.StopCompletely(false)

Note the desired-heading value written into CurrentTargetPosition.Frame is CLOBBERED by TurnToObject_Internal before it is ever read (Internal overwrites CurrentTargetPosition from the wire target and reads SoughtPosition.Frame's heading, which InitializeLocalVars reset to 0). Verified this is a retail quirk, not an ACE bug — the retail decomp does exactly the same (0052986c write → 0052a571 clobber → 0052a58d read-from-sought). Port it verbatim.

TurnToObject_Internal(targetPosition) (:261-282) — retail 0x0052a550 ✓

if (PhysicsObj == null) { CancelMoveTo(NoPhysicsObject); return }
CurrentTargetPosition = copy(targetPosition)
targetHeading = PhysicsObj.Position.heading(CurrentTargetPosition)
soughtHeading = SoughtPosition.Frame.get_heading()
heading = (targetHeading + soughtHeading) % 360      // retail: fmod(…, 360) via CIfmod
SoughtPosition.Frame.set_heading(heading)
PendingActions.Add(TurnToHeading node, heading)      // retail: node type 9
Initialized = true
BeginNextNode()

TurnToHeading(params) (:284-304) — retail 0x0052a630 — ⚠ flag A4

if (PhysicsObj == null) { MovementParams.ContextID = movementParams.ContextID; return }
if (movementParams.StopCompletely) PhysicsObj.StopCompletely(false)
MovementParams = copy(movementParams); MovementParams.Sticky = false   // retail __inner0 &= 0xffffff7f ✓
SoughtPosition.Frame.set_heading(movementParams.DesiredHeading)        // retail 0052a71d ✓
MovementType = TurnToHeading
PendingActions.Add(TurnToHeading node, DesiredHeading)
// ⚠ A4: ACE STOPS HERE. Retail 0052a76d calls BeginNextNode(this) immediately after the insert.

BeginNextNode (:316-349) — retail 0x00529cb0 — ⚠ flag A5 (weenie callback)

if (PendingActions non-empty):
    head.Type == MoveToPosition → BeginMoveForward()
    head.Type == TurnToHeading  → BeginTurnToHeading()
    (other types: fall through / return — retail explicitly returns for any other type)
else:
    if (MovementParams.Sticky):                  // retail: signed test on flags byte0 (bit 0x80)
        capture (radius, height, topLevelID) BEFORE CleanUp
        CleanUpAndCallWeenie(WeenieError.None)   // retail: CleanUp + StopCompletely inline — NO weenie call
        if (PhysicsObj != null)
            PhysicsObj.get_position_manager().StickTo(topLevelObjectID, radius, height)
    else:
        CleanUpAndCallWeenie(WeenieError.None)

BeginMoveForward (:351-399) — retail 0x00529a00 ✓

if (PhysicsObj == null) { CancelMoveTo(NoPhysicsObject); return }
dist = GetCurrentDistance()
heading = Position.heading(CurrentTargetPosition) - get_heading(); epsilon-normalize
MovementParams.get_command(dist, heading, ref motion, ref holdKey, ref moveAway)   // stored member ✓
if (motion == 0) { RemovePendingActionsHead(); BeginNextNode(); return }
mp = new MovementParameters { HoldKeyToApply = holdKey,      // the get_command RESULT — retail 00529af4 ✓
                              CancelMoveTo = false,          // retail flags &= 0xffff7fff ✓
                              Speed = MovementParams.Speed }
result = _DoMotion(motion, mp); if (result != None) { CancelMoveTo(result); return }
CurrentCommand = motion; MovingAway = moveAway
MovementParams.HoldKeyToApply = holdKey                      // write-back onto stored params — retail 00529b45 ✓
PreviousDistance = OriginalDistance = dist
PreviousDistanceTime = OriginalDistanceTime = now

HandleMoveToPosition (:404-504) — per-tick chase driver — retail 0x00529d80 — ⚠ flags A6, A8, A9

if (PhysicsObj == null) { CancelMoveTo(NoPhysicsObject); return }
curPos = copy(Position)
mp = new MovementParameters { CancelMoveTo = false, Speed = MovementParams.Speed,
                              HoldKeyToApply = MovementParams.HoldKeyToApply }    // retail 00529dc7-00529dea ✓
if (!PhysicsObj.IsAnimating):                    // retail: motions_pending() == 0
    heading = MovementParams.get_desired_heading(CurrentCommand, MovingAway) + curPos.heading(CurrentTargetPosition)
    if (heading >= 360) heading -= 360
    diff = heading - get_heading(); epsilon-normalize (0 clamp, +360 if < -EPSILON)
    if (diff > 20 && diff < 340):                // retail constants 20 / (360-20) ✓
        motionID = (diff >= 180) ? TurnLeft : TurnRight
        if (motionID != AuxCommand) { _DoMotion(motionID, mp); AuxCommand = motionID }
    else:
        if (AuxCommand != 0) PhysicsObj.set_heading(heading, true)   // ⚠ A6: ACE-ONLY ("custom: sync for server ticrate")
        stop_aux_command(mp)                     //     retail only stops the aux command, no snap
else:
    stop_aux_command(mp)                          // retail: aux stop under motions_pending ✓
dist = GetCurrentDistance()
if (!CheckProgressMade(dist)):
    if (!IsInterpolating() && !IsAnimating) FailProgressCount++     // retail identical gate ✓
else:
    inRange = false                               // ⚠ A8: whole inRange block is ACE-ONLY ("custom for low monster update rate")
    if (!MovementParams.UseSpheres):              //     including the extra PreviousDistance/Time refresh
        if (dist < 1.0 && PreviousDistance < dist) inRange = true
        PreviousDistance = dist; PreviousDistanceTime = now
    FailProgressCount = 0
    if (MovingAway && dist >= MinDistance || !MovingAway && dist <= DistanceToObject || inRange):
        PendingActions.RemoveAt(0)
        _StopMotion(CurrentCommand, mp); CurrentCommand = 0
        stop_aux_command(mp)                      // ⚠ A9: retail stops aux INSIDE the same block, before BeginNextNode — same order ✓
        BeginNextNode()
    else:
        if (StartingPosition.Distance(Position) > MovementParams.FailDistance)
            CancelMoveTo(YouChargedTooFar)        // 0x3D — retail same constant (00529f79)
if (TopLevelObjectID != 0 && MovementType != Invalid):
    v = |get_velocity()|
    if (v > 0.1):                                 // retail double literal 0.1 ✓
        time = dist / v
        if (|time - get_target_quantum()| > 1.0) set_target_quantum(time)   // retail 1.0 ✓

Arrival predicate cross-check: flee branch is explicit in the retail decomp (dist >= min_distance → arrival, 00529f44-00529f51). The chase branch's literal BN rendering appears inverted (dist <= distance_to_object branching to the fail-distance check) — that is a known test ah, 0x41 branch-sense garble; the flee branch + symmetric structure + two prior independent research passes (see RemoteMoveToDriver.cs:186-199) fix the correct reading as: chase arrives at dist <= distance_to_object, flee arrives at dist >= min_distance — which is exactly ACE's line 476. (The stale class-doc comment in acdream's RemoteMoveToDriver.cs:53-57 claiming "ACE swaps the predicates vs retail" is WRONG and superseded by the later comment in the same file — see §6 blast radius.)

BeginTurnToHeading (:510-565) — retail 0x00529b90 — ⚠ flags A7, A10

if (PhysicsObj == null) { CancelMoveTo(NoPhysicsObject); return }
                                                  // ⚠ A10: retail ALSO CancelMoveTo(8) when pending_actions head == null;
                                                  //        ACE indexes PendingActions[0] later (throws if empty — callers guard)
if (PhysicsObj.IsAnimating && !AlwaysTurn) return // ⚠ A7: retail is a plain motions_pending()!=0 → return; AlwaysTurn is ACE-only
headingDiff = heading_diff(node.Heading, get_heading(), TurnRight)
if (headingDiff <= 180):
    if (headingDiff > EPSILON) motionID = TurnRight
    else { RemovePendingActionsHead(); BeginNextNode(); return }
else:
    if (headingDiff + EPSILON <= 360) motionID = TurnLeft
    else { RemovePendingActionsHead(); BeginNextNode(); return }
mp = new MovementParameters { CancelMoveTo = false, Speed = MovementParams.Speed,
                              HoldKeyToApply = MovementParams.HoldKeyToApply }
result = _DoMotion(motionID, mp); if (result != None) { CancelMoveTo(result); return }
CurrentCommand = motionID
PreviousHeading = headingDiff                     // stores the DIFF, not a heading — retail 00529c82 identical quirk

(Retail decomp's turn-direction branch senses are x87-garbled here too; the retail structure — heading_diff(node, current, TurnRight), ≤180 → TurnRight, else TurnLeft, pop-when-within-epsilon — matches ACE's reading. Constants: 180, 360, EPSILON.)

HandleTurnToHeading (:570-621) — per-tick turn driver — retail 0x0052a0c0 ✓

if (PhysicsObj == null) { CancelMoveTo(NoPhysicsObject); return }
if (CurrentCommand != TurnRight && CurrentCommand != TurnLeft) { BeginTurnToHeading(); return }
node = PendingActions[0]; heading = get_heading()
if (heading_greater(heading, node.Heading, CurrentCommand)):     // crossed the target heading
    FailProgressCount = 0
    PhysicsObj.set_heading(node.Heading, true)                    // snap exactly — retail 0052a146 ✓ (this snap IS retail)
    RemovePendingActionsHead()
    mp = new MovementParameters { CancelMoveTo = false, HoldKeyToApply = MovementParams.HoldKeyToApply }
    _StopMotion(CurrentCommand, mp); CurrentCommand = 0
    BeginNextNode(); return
diff = heading_diff(heading, PreviousHeading, CurrentCommand)
if (diff > EPSILON && diff < 180):
    FailProgressCount = 0; PreviousHeading = heading
else:
    PreviousHeading = heading
    if (!IsInterpolating() && !IsAnimating) FailProgressCount++

Note ACE omits movementParams.Speed in the stop-params here (retail also builds a default-speed params for the stop — retail 0052a16a copies only hold_key + clears CancelMoveTo; ACE ✓).

HandleUpdateTarget(TargetInfo) (:623-666) — retail 0x0052a7d0 ✓ (reordered but equivalent)

if (PhysicsObj == null) { CancelMoveTo(NoPhysicsObject); return }
if (TopLevelObjectID != targetInfo.ObjectID) return
if (Initialized):
    if (status == OK):
        if (MovementType == MoveToObject):
            SoughtPosition = copy(InterpolatedPosition); CurrentTargetPosition = copy(TargetPosition)
            PreviousDistance = OriginalDistance = float.MaxValue
            PreviousDistanceTime = OriginalDistanceTime = now
    else CancelMoveTo(ObjectGone)                // 0x37 — retail same (0052a80f)
else if (TopLevelObjectID == PhysicsObj.ID):
    SoughtPosition = CurrentTargetPosition = copy(Position)
    CleanUpAndCallWeenie(None)
else if (status == OK):
    MovementType == MoveToObject → MoveToObject_Internal(TargetPosition, InterpolatedPosition)
    MovementType == TurnToObject → TurnToObject_Internal(TargetPosition)
else CancelMoveTo(NoObject)                      // 0x38 — retail same (0052a8bd)

Retail evaluates !initialized first and nests the self-target + status checks inside it; ACE flipped to if (Initialized) first. Same reachable behavior; error codes verified identical (0x37 initialized-path, 0x38 uninitialized-path — no transposition).

CheckProgressMade(currDistance) (:668-688) — retail 0x005290f0 ✓

deltaTime = now - PreviousDistanceTime
if (deltaTime > 1.0):                            // 1-second window
    diffDist = MovingAway ? curr - PreviousDistance : PreviousDistance - curr
    if (diffDist / deltaTime < 0.25) return false            // 0.25 m/s minimum closing rate
    PreviousDistance = curr; PreviousDistanceTime = now
    dOrigDist = MovingAway ? curr - OriginalDistance : OriginalDistance - curr
    if (dOrigDist / (now - OriginalDistanceTime) < 0.25) return false
return true

CancelMoveTo(retval) (:690-699) — retail 0x00529930 ✓

if (MovementType == Invalid) return
PendingActions.Clear()                           // retail: DLList drain + delete
CleanUpAndCallWeenie(retval)

CleanUp (:701-719) — retail 0x005295c0 ✓

mp = new MovementParameters { HoldKeyToApply = MovementParams.HoldKeyToApply, CancelMoveTo = false }
if (PhysicsObj != null):
    if (CurrentCommand != 0) _StopMotion(CurrentCommand, mp)
    if (AuxCommand != 0) _StopMotion(AuxCommand, mp)
    if (TopLevelObjectID != 0 && MovementType != Invalid) PhysicsObj.clear_target()
InitializeLocalVars()

CleanUpAndCallWeenie(status) (:721-729) — retail 0x00529650 — ⚠ flag A5

CleanUp()
if (PhysicsObj != null) PhysicsObj.StopCompletely(false)
WeenieObj.OnMoveComplete(status)                 // ⚠ ACE "server custom": retail body is CleanUp + StopCompletely ONLY
                                                 //    (despite the function name, the 2013 client build has no weenie
                                                 //    call here — compiled out / client-side no-op).
                                                 // ⚠ also: WeenieObj is NOT null-checked while PhysicsObj is → NPE if
                                                 //    constructed via the parameterless ctor (flag A11).

GetCurrentDistance (:731-741) — retail 0x005291b0

if (PhysicsObj == null) return float.MaxValue
if (!UseSpheres flag) return Position.Distance(CurrentTargetPosition)
return Position.CylinderDistance(GetRadius(), GetHeight(), Position,
                                 SoughtObjectRadius, SoughtObjectHeight, CurrentTargetPosition)

Distance is measured against CurrentTargetPosition in both variants; the sphere variant folds in both bodies' radius/height (this is why ACE chase packets can carry distance_to_object = 0.6 and still stop at melee range).

HitGround (:743-747) — retail 0x00529d70 ✓

if (MovementType != Invalid) BeginNextNode()

UseTime (:765-787) — retail 0x0052a780 — ⚠ flag A12 (gate inversion)

if (PhysicsObj == null || !TransientState.Contact) return    // retail: transient_state & 1 ✓
if (PendingActions.Count == 0) return
if (TopLevelObjectID != 0 || MovementType != Invalid || Initialized):   // ⚠ A12: retail gate is
    head.Type == MoveToPosition → HandleMoveToPosition()                //   (top_level_object_id == 0
    head.Type == TurnToHeading  → HandleTurnToHeading()                 //    || movement_type == Invalid
                                                                        //    || initialized != 0)

Retail (0052a7b4): proceed when topLevel == 0 || type == Invalid || initialized — i.e. skip only while a MoveToObject/TurnToObject is still waiting for its first target resolution. ACE negated the first two clauses. In practice ACE is masked by the PendingActions.Count == 0 early return (nodes only exist after *_Internal ran, which also sets Initialized), but port the retail condition verbatim — the masked edge (stale nodes surviving a CleanUp that doesn't clear PendingActions) differs.

_DoMotion / _StopMotion (:789-815) — retail 0x00529010 / 0x00529080 ✓

if (PhysicsObj == null) return NoPhysicsObject          // 8
minterp = PhysicsObj.get_minterp(); if null return NoMotionInterpreter   // 0xB
minterp.adjust_motion(ref motion, ref movementParams.Speed, movementParams.HoldKeyToApply)
return minterp.DoInterpretedMotion(motion, movementParams)      // _StopMotion → StopInterpretedMotion

The adjust-then-dispatch double (DoInterpretedMotion internally copies params and adjusts again — MotionInterp.cs:117-129) is retail-faithful: retail's 0x00529057 calls CMotionInterp::adjust_motion and CMotionInterp::DoInterpretedMotion also adjusts internally. Do NOT "fix" the double adjust.

heading_diff(h1, h2, motion) (:817-828) — retail free fn 0x00528fb0 (x87-garbled)

result = h1 - h2
if (|result| < EPSILON) result = 0
if (result < -EPSILON) result += 360
if (result > EPSILON && motion != TurnRight) result = 360 - result   // mirror for TurnLeft

The BN pseudo-C for the retail fn is return-value garbled (float in ST0 rendered as a flags-word int) and doesn't show the motion != TurnRight mirror — the R4 port should verify the mirror once in Ghidra (/decompile_function?address=0x00528fb0), but ACE's shape is consistent with every call site (BeginTurnToHeading passes TurnRight explicitly to get the raw clockwise diff).

heading_greater(h1, h2, motion) (:830-858) — retail free fn 0x00528f60 ✓

diff = |h1 - h2|
if (diff <= 180): result = h1 > h2
else:             result = h2 > h1
if (motion != TurnRight) result = !result
return result

ACE's rewrite verified equivalent to the retail comparison tree (including the equality case — equal headings → false for TurnRight). The original decompiled expression is preserved in an ACE comment at :832-836.

is_moving_to / stop_aux_command / small helpers (:860-872, :306-314, :749-763)

is_moving_to() => MovementType != Invalid
stop_aux_command(mp): if (AuxCommand != 0) { _StopMotion(AuxCommand, mp); AuxCommand = 0 }
AddMoveToPositionNode(): PendingActions.Add(node(MoveToPosition))       // retail node type 7
AddTurnToHeadingNode(h): PendingActions.Add(node(TurnToHeading, h))     // retail node type 9
RemovePendingActionsHead(): if (Count > 0) RemoveAt(0)
SetPhysicsObject / SetWeenieObject: plain assignments

2. ACE MovementParameters (Physics/Animation/MovementParameters.cs)

Bool-per-flag class; Flags property round-trips through MovementParamFlagsHelper (MovementParamFlags.cs): CanWalk 0x1, CanRun 0x2, CanSidestep 0x4, CanWalkBackwards 0x8, CanCharge 0x10, FailWalk 0x20, UseFinalHeading 0x40, Sticky 0x80, MoveAway 0x100, MoveTowards 0x200, UseSpheres 0x400, SetHoldKey 0x800, Autonomous 0x1000, ModifyRawState 0x2000, ModifyInterpretedState 0x4000, CancelMoveTo 0x8000, StopCompletely 0x10000, DisableJumpDuringLink 0x20000. Matches acclient.h bit assignments (cross-checked against acdream UpdateMotion.cs wire comments).

Defaults (ctor, :52-85) — retail 0x00524380 — ⚠ flags A13, A14

Field ACE default Retail default
flags CanWalk+CanRun+CanSidestep+CanWalkBackwards+CanCharge+MoveTowards+UseSpheres+SetHoldKey+ModifyRawState+ModifyInterpretedState+CancelMoveTo+StopCompletely = 0x1EE1F 0x1EE0F (CanCharge 0x10 CLEAR) — ⚠ A13
DistanceToObject 0.6f 0.6f ✓
FailDistance float.MaxValue FLT_MAX ✓
MinDistance 0 0 ✓
Speed 1.0f 1.0f ✓
WalkRunThreshold 1.0f (15.0f commented out) 15.0f — ⚠ A14 (ACE server tuning)
DesiredHeading 0 0 ✓
HoldKeyToApply Invalid Invalid ✓
ContextID / ActionStamp 0 0 ✓

(Retail caches the default flag word in a static set_moveto_flags::normal_bitfield — one-time compute of 0x1EE0F, 005243c1-005243e1.)

Copy constructors (:90-150)

  • MovementParameters(mvp, onlyBits=false) — copies Flags always, plus non-flag fields (CopyNonFlag: DistanceToObject, FailDistance, DesiredHeading, MinDistance, Speed, WalkRunThreshold, ContextID, HoldKeyToApply, ActionStamp) unless onlyBits. Retail assigns field-by-field at each MoveTo*/TurnTo* site — full copy. Equivalent.
  • CopySome(mvp) — used by MotionInterp.DoInterpretedMotion/StopMotion: copies the "capability + behavior" flags and all non-flag fields EXCEPT FailWalk, UseFinalHeading, Sticky, MoveAway, Autonomous, DisableJumpDuringLink, DesiredHeading, ContextID, ActionStamp. (ACE TODO: review — interop with the R3 MotionInterp port, not an R4 concern per se.)
  • MovementParameters(MoveToParameters) — wire-side ctor (server outbound); flags from the packed dword + the six floats. Matches MovementParameters::UnPackNet 0x0052ac50 field order.

get_command(dist, heading, ref motion, ref holdKey, ref movingAway) (:152-184) — retail 0x0052aa00 — ⚠ flag A15 (CanCharge gate dropped)

ACE:

if (MoveTowards || !MoveAway):
    if (MoveAway): towards_and_away(dist, heading, ref motion, ref movingAway)
    else:
        if (dist > DistanceToObject) { motion = WalkForward; movingAway = false } else motion = 0
else if (MoveAway):
    if (dist < MinDistance) { motion = WalkForward; movingAway = true } else motion = 0
if (CanRun && (!CanWalk || dist - DistanceToObject > WalkRunThreshold)) holdKey = Run
else holdKey = None

Retail (0052aa00, byte-1 flag reads = bits 0x100/0x200; verified decode):

if (MoveTowards):
    if (MoveAway) towards_and_away(...)          // both set → bidirectional band
    else chase-only (dist > distance_to_object → WalkForward, away=0; else 0)
else if (!MoveAway): chase-only                  // neither set → still chase-only (matches ACE's OR)
else: flee-only (dist < min_distance → WalkForward, away=1; else 0)
// HoldKey (0052aa90-0052aab4):
if (flags & 0x10 /*CanCharge*/) → HoldKey_Run                           // ⚠ unconditional run fast-path
else if (!(flags & 0x2 /*CanRun*/)) → HoldKey_None
else if ((flags & 0x1 /*CanWalk*/) && dist - distance_to_object <= walk_run_threshhold) → HoldKey_None
else → HoldKey_Run

Movement-command selection matches ACE exactly. The hold-key selection does NOT: retail's CanCharge (0x10) bit short-circuits straight to HoldKey_Run before any CanRun/CanWalk/threshold logic; ACE dropped it entirely. (This is the already-documented #77 finding — memory/feedback_autowalk_cancharge_bit.md; acdream's B.6 honors the wire CanCharge bit.) Note the two ACE deviations A13 (default CanCharge=true) + A15 (gate removed) roughly cancel within ACE's own server, but a verbatim port must take retail's version of BOTH.

towards_and_away(dist, heading, ref command, ref movingAway) (:200-214) — retail 0x0052a9a0 ✓

if (dist > DistanceToObject) { command = WalkForward; movingAway = false }
else if (dist - MinDistance < EPSILON) { command = WalkBackwards; movingAway = true }   // retail 0x45000006 ✓
else command = 0

(Note: heading param is unused in both ACE and retail.)

get_desired_heading(motion, movingAway) (:186-198) — retail 0x0052aad0 ✓

RunForward / WalkForward → movingAway ? 180 : 0
WalkBackwards            → movingAway ? 0 : 180
default                  → 0

3. Call sites into MoveToManager (ACE)

MovementManager (Physics/Managers/MovementManager.cs) — owns exactly one MotionInterpreter

  • one MoveToManager:
  • PerformMovement(mvs) (:124-157): PhysicsObj.set_active(true) (ACE-ism, server activity tracking), then dispatch — types 1-5 → MotionInterp.PerformMovement (lazy-create + enter_default_state), types 6-9 → MoveToManager.PerformMovement (lazy-create), default → WeenieError.GeneralMovementFailure.
  • CancelMoveTo(err) (:27), HandleUpdateTarget(targetInfo) (:60), UseTime() (:176, forwards to MoveToManager only), HitGround() (:66-73, forwards to BOTH MotionInterp.HitGround and MoveToManager.HitGround), IsMovingTo() (:97), MakeMoveToManager() (:112), SetWeenieObject (:167).

PhysicsObj (Physics/PhysicsObj.cs) seams that drive the manager:

  • UseTime ticks: :1729 + :1791 (MovementManager.UseTime() from the per-tick update paths, followed by PositionManager.UseTime()).
  • set_on_walkable (:3791-3814): OnWalkable rising edge → MovementManager.HitGround(), falling edge → LeaveGround(). Additional LeaveGround sites :1243, :2242, :2911 (clear_transient_states and friends).
  • cancel_moveto() (:2143-2147): → MovementManager.CancelMoveTo(ActionCancelled). Called from MotionInterp on every user-motion (DoInterpretedMotion :123-124, StopMotion :371-372, StopCompletely :305, jump :714, move_to_interpreted_state :794) — i.e. any fresh motion with the CancelMoveTo param bit set kills the active MoveTo.
  • HandleUpdateTarget(targetInfo) (:637-644): forwards to MovementManager AND PositionManager.
  • Teleport: :4036-4037CancelMoveTo(WeenieError.ITeleported).
  • StopCompletely(bool) (:1546-1551): builds MovementStruct(StopCompletely) → MovementManager.PerformMovement → MotionInterp.StopCompletely (which itself calls cancel_moveto — recursion is broken because CancelMoveTo no-ops when MovementType == Invalid, and CleanUp runs InitializeLocalVars BEFORE the StopCompletely call in CleanUpAndCallWeenie).
  • set_target(ctx, objID, radius, quantum) (:3947), clear_target() (:2231), get_target_quantum() (:2638, returns TargetManager.TargetInfo.Quantum or 0), set_target_quantum(q) (:3955), unstick_from_object() (:4134 → PositionManager.Unstick).

TargetManager / TargetInfo (Physics/Managers/TargetManager.cs, Physics/Combat/TargetInfo.cs): SetTarget clears old target, registers this obj as a voyeur on the target (target.add_voyeur(ID, radius, quantum)); target movement beyond radius from last-sent → SendVoyeurUpdatevoyeurObj.receive_target_updateTargetManager.ReceiveUpdatePhysicsObj.HandleUpdateTarget → MoveToManager.HandleUpdateTarget. TargetStatus: Undefined=0, OK=1, ExitWorld=2, Teleported=3, Contained=4, Parented=5, TimedOut=6. HandleTargetting runs on a 0.5 s cadence with a 10 s Undefined→TimedOut timeout. This whole subsystem is the "target re-tracking" acdream currently skips (server re-emits MoveTo ~1 Hz instead).

MotionInterp seams used by MoveToManager: adjust_motion(ref motion, ref speed, holdKey) (MotionInterp.cs:394-428 — WalkBackwards→WalkForward·BackwardsFactor, TurnLeft→TurnRight·1, SideStepLeft→SideStepRight·1, sidestep scale, holdKey Invalid→RawState.CurrentHoldKey, Run→ apply_run_to_command), DoInterpretedMotion, StopInterpretedMotion. These are R3-ported in acdream's MotionInterpreter already.


4. ACE-ism / oddity flags for the R4 retail cross-check

Ranked. "RETAIL-VERIFIED" = I read the named decomp this session; anchors above.

  • A1 — stale-member UseFinalHeading read in MoveToPosition (MoveToManager.cs:214). ACE reads MovementParams.UseFinalHeading (the PREVIOUS move's stored params — the new ones aren't assigned until :222); retail 0052a33b reads the ARGUMENT's flag (arg3->__inner0 & 0x40). RETAIL-VERIFIED divergence — exactly the R3 NPE/transposition class. Port the argument read.
  • A2 — InitializeLocalVars zeroes the wrong field (:68). ACE: MovementParams.DistanceToObject = 0. Retail 0052925b: movement_params.__inner0 = 0 (the FLAGS word) + context_id = 0. RETAIL-VERIFIED transposition.
  • A3 — InitializeLocalVars omits distance resets. Retail 0052926c/0052928a also set previous_distance = original_distance = FLT_MAX; ACE leaves them stale (masked because BeginMoveForward reseeds them). RETAIL-VERIFIED.
  • A4 — TurnToHeading missing BeginNextNode (:284-304). Retail 0052a76d calls BeginNextNode() immediately after queuing the node; ACE returns without it (turn starts one UseTime tick late, and an empty-queue Sticky/complete path can never fire from TurnToHeading). RETAIL-VERIFIED control-flow divergence.
  • A5 — CleanUpAndCallWeenie weenie callback (:728). WeenieObj.OnMoveComplete(status) is an ACE server addition (drives the server's MoveToChain completion). Retail 0x00529650 is CleanUp + StopCompletely only. Client-side R4 equivalent: this is where acdream's AutoWalkArrived-style completion notification belongs architecturally — but retail does NOT notify anything here. RETAIL-VERIFIED.
  • A6 — HandleMoveToPosition aligned-branch set_heading snap (:444-446). ACE-only, self-labeled "custom: sync for server ticrate". Retail only stops the aux command when within the ±20° band — the residual alignment is finished by the normal per-tick turn integration. acdream runs at client tick rates → do NOT port the snap. RETAIL-VERIFIED absent.
  • A7 — AlwaysTurn field (:35, used :520). ACE-only override of the IsAnimating gate in BeginTurnToHeading (server emote-turn convenience). No retail counterpart. RETAIL-VERIFIED absent.
  • A8 — HandleMoveToPosition inRange block (:463-473). ACE-only ("custom for low monster update rate"): when !UseSpheres, arrival also fires if dist < 1.0 && PreviousDistance < dist (distance started increasing inside 1 m), plus an extra PreviousDistance/Time refresh outside CheckProgressMade. Retail has neither. RETAIL-VERIFIED absent — do not port.
  • A9 — (non-flag) arrival stop order — ACE pops the node, stops CurrentCommand, zeroes it, stops aux, BeginNextNode — same order as retail 00529f94-00529fef. Listed only so nobody "fixes" it.
  • A10 — BeginTurnToHeading missing empty-head cancel. Retail 00529bad treats pending_actions.head == null the same as physics-obj-null → CancelMoveTo(8); ACE would throw on PendingActions[0] instead (unreachable through current callers, but port the retail guard). RETAIL-VERIFIED.
  • A11 — CleanUpAndCallWeenie NPE asymmetry (:721-729). PhysicsObj is null-checked, WeenieObj is not — the parameterless MoveToManager() ctor leaves WeenieObj null → NPE on any cancel. Same null-check-typo class the R3 pass caught in ACE. Irrelevant once A5 is resolved client-side, but don't transcribe.
  • A12 — UseTime gate negation (:775). ACE: TopLevelObjectID != 0 || MovementType != Invalid || Initialized. Retail 0052a7b4: top_level_object_id == 0 || movement_type == Invalid || initialized != 0. The first two clauses are NEGATED in ACE (masked by the empty-queue early-out; still, port retail's). RETAIL-VERIFIED boundary inversion.
  • A13 — default CanCharge flag. ACE ctor sets CanCharge=true (flags 0x1EE1F); retail default is 0x1EE0F (CanCharge clear). RETAIL-VERIFIED.
  • A14 — Default_WalkRunThreshold = 1.0f vs retail 15.0f (0x005243b5). ACE deliberately retuned (15.0f left commented out at MovementParameters.cs:49-50). acdream already treats 15 m as the retail/wire default (#77 work). RETAIL-VERIFIED.
  • A15 — get_command drops the CanCharge fast-path. Retail 0052aa95: flags & 0x10 → unconditional HoldKey_Run BEFORE the CanRun/CanWalk/threshold cascade; ACE has only the cascade. A13+A15 cancel inside ACE's ecosystem; a verbatim port needs retail's version of both. RETAIL-VERIFIED (and consistent with memory/feedback_autowalk_cancharge_bit.md).
  • A16 — server-tick constants to re-check at 30 Hz. ACE runs MoveToManager on its own cadence; the constants (20°/340° aux band, 1.0 s / 0.25 m/s progress, 0.1 velocity floor, 1.0 quantum hysteresis) are retail-identical (verified), so no rescaling is needed — but the feel of the aux-turn band differs with tick rate; retail integrates at 30 Hz physics ticks.

BN-artifact warnings for the R4 decomp pass (do NOT trust the literal pseudo-C)

  • HandleMoveToPosition chase-arrival branch (00529f8f test ah,0x41) renders INVERTED — the flee branch (explicit bit math) + ACE + two prior research passes fix the truth: chase arrives at dist <= distance_to_object. See §1 HandleMoveToPosition note.
  • BeginTurnToHeading turn-direction branches (00529bf4/00529c23 test ah,0x41/0x5) — senses garbled; ACE's ≤180→TurnRight reading is correct.
  • heading_diff (0x00528fb0) — x87 return garble; the motion != TurnRight → 360 - result mirror is invisible in the pseudo-C. Verify once via Ghidra MCP before porting.
  • get_command's first flag read is BN-rendered as a bogus (int16_t) pointer deref of the flags word — it's just flags & 0x200 / & 0x100 on byte 1.

Negative results (look like ACE-isms, are retail-faithful)

  • FailProgressCount is vestigial in RETAIL too — grep shows only writes (reset/increment) at 005292b1 / 0052a014 / 00529f3e / 0052a13a / 0052a1e5 / 0052a224; no threshold read anywhere. Do not invent a cancel-after-N-failures mechanism.
  • Double adjust_motion (_DoMotion + DoInterpretedMotion-internal) — retail does the same.
  • TurnToObject desired-heading clobber (write CurrentTargetPosition → Internal overwrites and reads SoughtPosition) — retail quirk, verified against the PDB struct layout; port verbatim.
  • BeginTurnToHeading storing a DIFF into PreviousHeading — retail 00529c82 does the same.
  • PerformMovement returning success unconditionally — retail returns 0 too.
  • Error codes all line up (no ordinal traps): NoPhysicsObject=0x8, NoMotionInterpreter=0xB, ActionCancelled=0x36, ObjectGone=0x37, NoObject=0x38, YouChargedTooFar=0x3D — verified against both retail immediates and ACE's WeenieError enum.

5. acdream R4 blast radius — what exists today

All paths worktree-relative (.claude/worktrees/vigorous-joliot-f0c3ad).

src/AcDream.Core/Physics/RemoteMoveToDriver.cs (341 lines, static)

Per-tick steering for server-controlled remotes during mt 6/7. Approximates HandleMoveToPosition's aux-turn + arrival only:

  • Drive(bodyPos, bodyOrient, destWorld, minDistance, distanceToObject, dt, moveTowards, out newOrient) (:170-254): horizontal distance; arrival moveTowards ? dist <= distanceToObject + ε : dist >= minDistance ε (ArrivalEpsilon = 0.05 — acdream fudge, not retail); heading via quaternion yaw; ±20° snap-to-heading (HeadingSnapToleranceRad, borrowed from ACE's A6 custom snap — retail has no snap in the ≤20° band); otherwise rotate at TurnRateRadPerSec = π/2.
  • Constants: RunTurnFactor = 1.5 (retail run_turn_factor @ 0x007c8914), BaseTurnRateRadPerSec = π/2, TurnRateFor(running), StaleDestinationSeconds = 1.5 (acdream-only staleness guard — replaces retail's TargetManager re-tracking), ClampApproachVelocity (:296-329, acdream-only overshoot clamp), OriginToWorld (:263-277, landblock-local → render world).
  • ⚠ Its class-level doc (:53-57) still claims "ACE swaps the chase/flee arrival predicates vs retail" — WRONG, contradicted by its own later comment (:186-199) and by this document. Fix the doc comment during R4.
  • What it deliberately skips (doc :44-51): target re-tracking, Sticky/StickTo, fail-distance + progress detector, cylinder-sphere distance — the R4 port's main new surface.

src/AcDream.Core/Physics/ServerControlledLocomotion.cs (88 lines, static)

Chooses the visible cycle for remotes on mt 6/7 (which carry no ForwardCommand): PlanMoveToStart(moveToSpeed, runRate, canRun)BeginMoveForward → get_command → adjust_motion collapsed into "RunForward @ speed·runRate if canRun else WalkForward" — no distance/threshold logic at all (no DistanceToObject, no WalkRunThreshold, no CanCharge). PlanFromVelocity (fallback from observed velocity; StopSpeed=0.20, RunThreshold=1.25 — acdream heuristics, not retail). R4 replaces this seeding with real MovementParameters.get_command.

src/AcDream.App/Input/PlayerMovementController.cs — B.6 auto-walk block (KEEP-LIST until R4)

  • Fields :264-296: _autoWalkActive/_autoWalkDestination/_autoWalkMinDistance/ _autoWalkDistanceToObject/_autoWalkMoveTowards/_autoWalkInitiallyRunning; turn-direction tracker _autoWalkTurnDirectionThisFrame (:323); IsServerAutoWalking (:308); AutoWalkArrived event (:340, host re-sends the Use/PickUp action on natural arrival — ACE MoveToChain timeout workaround).
  • BeginServerAutoWalk(destWorld, minDistance, distanceToObject, moveTowards, canCharge) (:452-487): installs state; walk-vs-run is a ONE-SHOT decision from the wire CanCharge bit (#77 fix — matches retail A15 semantics relayed by ACE's server-side Creature.SetWalkRunThreshold).
  • EndServerAutoWalk(reason) (:495-503): idempotent; fires AutoWalkArrived on reason=="arrived".
  • DriveServerAutoWalk(dt, input) (:567-790) — the local-player approximation of HandleMoveToPosition + Begin/HandleTurnToHeading collapsed into one function: user-input cancel; horizontal distance; arrival moveTowards ? dist <= distanceToObject : dist >= minDistance + ε gated on 5° alignment (acdream-only: retail fires arrival purely on distance; the alignment gate exists because ACE rotates server-side before the Use callback); continuous turn at TurnRateFor(_autoWalkInitiallyRunning) with no snap (deliberately retail-er than RemoteMoveToDriver); 30° walk-while-turning band (acdream-only — retail's equivalent is the discrete TurnToHeading node THEN MoveToPosition node, plus the 20° aux band while moving); turn-in-place phase issues DoMotion(Ready) + zeroes horizontal velocity; forward phase issues DoMotion(WalkForward, runRate|1.0) + set_local_velocity from get_state_velocity. R4 replaces this whole function with real pending-node MoveToManager driving.
  • Call site: Update :896 (autoWalkConsumedMotion skips the user-input motion/velocity blocks); :1028 and :1528 carry R4 KEEP-LIST notes (auto-walk owns motion until R4 cutover).

src/AcDream.App/Rendering/GameWindow.cs — MoveTo packet handling

  • Inbound UM seeding :4401-4435: when update.MotionState.IsServerControlledMoveTo and ForwardCommand absent → ServerControlledLocomotion.PlanMoveToStart(MoveToSpeed, MoveToRunRate, MoveToCanRun) provides fullMotion/speedMod for the animation funnel.
  • Local player :4507-4547: mt 6/7 with MoveToPath payload → OriginToWorld_playerController.BeginServerAutoWalk(dest, MinDistance, DistanceToObject, MoveTowards, CanCharge). Deliberately does NOT cancel on the companion mt 0x00 InterpretedMotionState echo (:4534-4546, trace-verified ACE behavior).
  • Remotes :4570-4610: remoteMot.ServerMoveToActive = IsServerControlledMoveTo; path payload → MoveToDestinationWorld/MinDistance/DistanceToObject/MoveTowards/HasMoveToDestination/ LastMoveToPacketTime; non-MoveTo UM clears HasMoveToDestination.
  • Remote per-tick :9594-9682: staleness guard (1.5 s) → else RemoteMoveToDriver.Drive → orientation write; Arrived → zero velocity; Steering → rm.Motion.apply_current_movement + ClampApproachVelocity. MoveTo-active-but-no-path → hold velocity zero (882a07c stance).
  • Speculative install :12122-12164: InstallSpeculativeTurnToTarget — local prediction of ACE's MoveToObject before the wire packet arrives; predicts CanCharge via the 7.5 m rule (AceCanChargeDistance = WalkRunThreshold/2); per-type use radius (0.6 default / 3.0 creature / 2.0 door-lifestone-portal-corpse).
  • :4942: second PlanMoveToStart consumer (same seeding on another path — CreateObject-time).

Wire layer

  • src/AcDream.Core.Net/Messages/UpdateMotion.cs: parses mt 6/7 union → CreateObject.MoveToPathData (:157, :287-344); exposes IsServerControlledMoveTo (mt is 6 or 7), MoveToCanRun (params bit 0x2), MoveTowards (0x200), CanCharge (0x10), MoveToSpeed, MoveToRunRate.
  • src/AcDream.Core.Net/Messages/CreateObject.cs:292-302: MoveToPathData(TargetGuid, OriginCellId, OriginX/Y/Z, DistanceToObject, MinDistance, FailDistance, WalkRunThreshold, DesiredHeading) — the full retail MovementParameters float block is already captured on the wire; FailDistance and WalkRunThreshold and DesiredHeading are parsed but currently UNUSED by any driver (R4 consumers).
  • src/AcDream.Core/Physics/PhysicsDiagnostics.cs: ProbeAutoWalkEnabled (ACDREAM_PROBE_AUTOWALK) gates [autowalk-mt]/[autowalk-begin]/[autowalk-end] traces.

What R4 must reconcile

Today there are three independent approximations of one retail mechanism: DriveServerAutoWalk (local player), RemoteMoveToDriver.Drive (remotes), ServerControlledLocomotion.PlanMoveToStart (animation seed). Retail runs ONE MoveToManager per CPhysicsObj (player and remotes identical), driven by pending nodes + UseTime, issuing motions through CMotionInterp (already R3-ported as MotionInterpreter). The wire already delivers every field the retail manager needs (MoveToPathData + params bits). Missing retail apparatus: pending-node queue, TurnToHeading/ MoveToPosition node alternation, get_command/towards_and_away, the aux-turn band, CheckProgressMade + fail-distance cancel, HitGround → BeginNextNode, Sticky/StickTo (position manager), and the TargetManager re-tracking loop (acdream substitutes ACE's ~1 Hz MoveTo re-emit + the 1.5 s staleness guard — decide in R4 whether that substitution stays as a documented divergence-register row).