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>
857 lines
50 KiB
Markdown
857 lines
50 KiB
Markdown
# 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) 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-4037` → `CancelMoveTo(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 →
|
||
`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 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).
|