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>
50 KiB
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 →
HandleUpdateTarget → MoveToObject_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) unlessonlyBits. 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. (ACETODO: 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. MatchesMovementParameters::UnPackNet0x0052ac50 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:
UseTimeticks::1729+:1791(MovementManager.UseTime()from the per-tick update paths, followed byPositionManager.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-4037→CancelMoveTo(WeenieError.ITeleported). StopCompletely(bool)(:1546-1551): buildsMovementStruct(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, returnsTargetManager.TargetInfo.Quantumor 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 →
SendVoyeurUpdate → voyeurObj.receive_target_update → TargetManager.ReceiveUpdate →
PhysicsObj.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 readsMovementParams.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 callsBeginNextNode()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'sAutoWalkArrived-style completion notification belongs architecturally — but retail does NOT notify anything here. RETAIL-VERIFIED. - A6 — HandleMoveToPosition aligned-branch
set_headingsnap (: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 —
AlwaysTurnfield (:35, used:520). ACE-only override of the IsAnimating gate in BeginTurnToHeading (server emote-turn convenience). No retail counterpart. RETAIL-VERIFIED absent. - A8 — HandleMoveToPosition
inRangeblock (:463-473). ACE-only ("custom for low monster update rate"): when!UseSpheres, arrival also fires ifdist < 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 == nullthe same as physics-obj-null →CancelMoveTo(8); ACE would throw onPendingActions[0]instead (unreachable through current callers, but port the retail guard). RETAIL-VERIFIED. - A11 — CleanUpAndCallWeenie NPE asymmetry (
:721-729).PhysicsObjis null-checked,WeenieObjis not — the parameterlessMoveToManager()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→ unconditionalHoldKey_RunBEFORE 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 withmemory/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)
HandleMoveToPositionchase-arrival branch (00529f8ftest ah,0x41) renders INVERTED — the flee branch (explicit bit math) + ACE + two prior research passes fix the truth: chase arrives atdist <= distance_to_object. See §1 HandleMoveToPosition note.BeginTurnToHeadingturn-direction branches (00529bf4/00529c23test ah,0x41/0x5) — senses garbled; ACE's ≤180→TurnRight reading is correct.heading_diff(0x00528fb0) — x87 return garble; themotion != TurnRight → 360 - resultmirror 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 justflags & 0x200/& 0x100on 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; arrivalmoveTowards ? 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 atTurnRateRadPerSec = π/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);AutoWalkArrivedevent (: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-sideCreature.SetWalkRunThreshold).EndServerAutoWalk(reason)(:495-503): idempotent; firesAutoWalkArrivedonreason=="arrived".DriveServerAutoWalk(dt, input)(:567-790) — the local-player approximation of HandleMoveToPosition + Begin/HandleTurnToHeading collapsed into one function: user-input cancel; horizontal distance; arrivalmoveTowards ? 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 atTurnRateFor(_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 issuesDoMotion(Ready)+ zeroes horizontal velocity; forward phase issuesDoMotion(WalkForward, runRate|1.0)+set_local_velocityfromget_state_velocity. R4 replaces this whole function with real pending-node MoveToManager driving.- Call site:
Update:896(autoWalkConsumedMotionskips the user-input motion/velocity blocks);:1028and:1528carry 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: whenupdate.MotionState.IsServerControlledMoveToand ForwardCommand absent →ServerControlledLocomotion.PlanMoveToStart(MoveToSpeed, MoveToRunRate, MoveToCanRun)providesfullMotion/speedModfor the animation funnel. - Local player
:4507-4547: mt 6/7 withMoveToPathpayload →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 clearsHasMoveToDestination. - Remote per-tick
:9594-9682: staleness guard (1.5 s) → elseRemoteMoveToDriver.Drive→ orientation write; Arrived → zero velocity; Steering →rm.Motion.apply_current_movement+ClampApproachVelocity. MoveTo-active-but-no-path → hold velocity zero (882a07cstance). - 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: secondPlanMoveToStartconsumer (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); exposesIsServerControlledMoveTo(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 retailMovementParametersfloat 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).