docs(R4-V-1): MoveToManager research base — decomp extraction + ACE cross-ref + port work-list
Workflow-produced R4 research (3 docs, 2,541 lines): - r4-moveto-decomp.md: all 33 MoveToManager members verbatim (0x00529010-0x0052a987) + the MovementManager type-6/7/8/9 relay + MovementParameters::get_command. HandleUpdateTarget CONFIRMED on MoveToManager (0x0052a7d0 — closes the R3 negative result; object moves are DEFERRED until the first target-update callback). Walk-vs- run = the CanCharge rule exactly (can_charge OR can_run && dist-gap > walk_run_threshhold, riding hold_key_to_apply into DoInterpretedMotion — MoveToManager never calls set_hold_run). Sticky handoff located (empty queue + 0x80 → StickTo). fail_progress_count is WRITE-ONLY in retail (no give-up threshold — do not invent one). 8-class BN artifact ledger. - r4-ace-moveto.md: 16 flagged ACE-isms, all retail-verified — incl. a stale-member read (MoveToPosition checks the PREVIOUS move's UseFinalHeading), a field transposition (InitializeLocalVars zeroes DistanceToObject where retail zeroes the flags word), a dropped BeginNextNode (ACE turns start one tick late), a UseTime gate inversion, and the canceling CanCharge default+fast-path pair. Blast radius: acdream has THREE independent approximations of this one mechanism (DriveServerAutoWalk / RemoteMoveToDriver / ServerControlledLocomotion) + an outdated chase/flee claim in RemoteMoveToDriver's class doc. - r4-port-plan.md: 7 pins (P1 blocker: retail interrupts current movement on EVERY UM but ACE sends a companion mt-0 echo after each MoveTo — needs a discriminator pin before V5; P3: heading_diff's TurnLeft mirror needs one Ghidra decompile of 0x00528fb0; P4: type-6/8 moves need a minimal TargetTracker adapter until R5), 17 gaps M1-M17, commits V0-V6. Retires AD-8/AD-9/AP-8/AP-9 + TS-36. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
30115d96aa
commit
988304e13b
3 changed files with 2541 additions and 0 deletions
857
docs/research/2026-07-03-r4-moveto/r4-ace-moveto.md
Normal file
857
docs/research/2026-07-03-r4-moveto/r4-ace-moveto.md
Normal file
|
|
@ -0,0 +1,857 @@
|
||||||
|
# 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).
|
||||||
1232
docs/research/2026-07-03-r4-moveto/r4-moveto-decomp.md
Normal file
1232
docs/research/2026-07-03-r4-moveto/r4-moveto-decomp.md
Normal file
File diff suppressed because it is too large
Load diff
452
docs/research/2026-07-03-r4-moveto/r4-port-plan.md
Normal file
452
docs/research/2026-07-03-r4-moveto/r4-port-plan.md
Normal file
|
|
@ -0,0 +1,452 @@
|
||||||
|
# R4 port work-list — MoveToManager verbatim + the three-approximation cutover
|
||||||
|
|
||||||
|
Inputs: `r4-moveto-decomp.md` (verbatim retail extraction, same dir),
|
||||||
|
`r4-ace-moveto.md` (ACE cross-ref + blast radius, same dir), plan of record
|
||||||
|
`docs/plans/2026-07-02-retail-motion-animation-rewrite.md` (stage R4), R3 pins
|
||||||
|
`docs/research/2026-07-02-r3-motioninterp/W0-pins.md` (A4 MovementParameters masks —
|
||||||
|
used as-is), current code `src/AcDream.Core/Physics/RemoteMoveToDriver.cs` (340
|
||||||
|
lines, static), `src/AcDream.Core/Physics/ServerControlledLocomotion.cs` (87 lines),
|
||||||
|
`src/AcDream.App/Input/PlayerMovementController.cs` (1604 lines, post-R3-W6; B.6
|
||||||
|
auto-walk block :275-:766 + :896), `src/AcDream.App/Rendering/GameWindow.cs`
|
||||||
|
(local MoveTo routing :4401-4547, remote seeding :4549-4620, remote per-tick
|
||||||
|
:9594-9682, speculative install :11915/:12004/:12122),
|
||||||
|
`src/AcDream.Core.Net/Messages/UpdateMotion.cs` (mt 6/7 parse :252-356),
|
||||||
|
`src/AcDream.Core/Physics/Motion/MovementParameters.cs` (R3-W1, flags+scalars only),
|
||||||
|
`src/AcDream.Core/Physics/MotionInterpreter.cs` (R3-complete: DoInterpretedMotion
|
||||||
|
:2861, StopInterpretedMotion :3016, adjust_motion :1241, MotionsPending :2118,
|
||||||
|
InterruptCurrentMovement seam :645, UnstickFromObject seam :633, MyRunRate :585).
|
||||||
|
|
||||||
|
**Precondition / state at R4 start:** R1/R2/R3 SHIPPED (R3 through W6 `fb7beb70`;
|
||||||
|
R2+R3 share one pending visual pass — R4 code work does not block on it). The
|
||||||
|
R3 seams R4 consumes all exist: `DoInterpretedMotion(uint, MovementParameters)` /
|
||||||
|
`StopInterpretedMotion(uint, MovementParameters)` / `adjust_motion(ref,ref,HoldKey)`
|
||||||
|
(the entire retail `_DoMotion` shape), `MotionsPending()`, `TransientStateFlags.Contact`
|
||||||
|
on `PhysicsObj`, `HitGround`/`LeaveGround` (W4), the `InterruptCurrentMovement`
|
||||||
|
no-op `Action?` seam (register TS-36 — "R4 is where cancel_moveto lands"), and
|
||||||
|
`MovementParameters` with retail ctor defaults (0x1EE0F, threshhold 15, CanCharge
|
||||||
|
false). What does NOT exist: any MoveToManager, the command-selection family
|
||||||
|
(`get_command`/`towards_and_away`/`get_desired_heading`), MovementType values
|
||||||
|
0/6/7/8/9, WeenieError 0xB/0x36/0x37/0x38/0x3D, mt 8/9 wire parsing, and any
|
||||||
|
heading/cylinder-distance helper in Core. MovementManager itself is **R5** —
|
||||||
|
R4 binds MoveToManager to the interp directly and keeps type-dispatch at the
|
||||||
|
existing call sites (same "null-guarded relay inserted later without behavior
|
||||||
|
change" pattern R3 §4 used for MotionDone).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. DECOMP AMBIGUITIES TO PIN before porting (the V0 pins commit)
|
||||||
|
|
||||||
|
| # | Ambiguity | Evidence each way | Pin method |
|
||||||
|
|---|---|---|---|
|
||||||
|
| P1 | **ACE's companion mt-0 echo vs retail's unpack-head cancel — the V5 landmine.** Retail `MovementManager::unpack_movement` (0x00524440 @300566-300598) calls `CPhysicsObj::interrupt_current_movement` (→ CancelMoveTo 0x36) at the HEAD of EVERY movement event, including case 0. But ACE follows every mt=0x06 MoveToObject with an mt=0x00 InterpretedMotionState echo (cmd=RunForward, fwdSpd≈2.86) one packet later — trace-verified 2026-05-14 (`launch-slice2.log`; the finding is preserved verbatim in GameWindow.cs:4534-4546: "Cancelling on the InterpretedMotionState killed the auto-walk on frame 1"). A verbatim head-interrupt port re-breaks auto-walk on frame 1 against ACE. Retail servers evidently never sent an interpreted UM mid-moveto to the mover (any interpreted UM IS the cancel+replace signal). | decomp §2f head vs GameWindow:4534-4546 trace note; ACE `Player.OnMoveComplete`/MoveToChain broadcast code | (a) Re-capture the echo with `ACDREAM_PROBE_AUTOWALK=1` + `ACDREAM_DUMP_MOTION=1` and record the echo's movement/serverControl sequence stamps + isAutonomous flag vs the mt-6 packet's; (b) read ACE's broadcast path (does the echo bump ServerControlSequence? is it flagged autonomous?) for a principled discriminator; (c) if no wire discriminator exists, the adaptation is: **suppress the head-interrupt for a local-player mt-0 UM whose interpreted ForwardCommand equals the moveto's `current_command` (post-adjust) while `is_moving_to()`** — an ACE-compat register row (AD class, sibling of AD-32), NOT a silent divergence. Whatever the pin, mt 6/7/8/9 arrivals keep the retail unconditional cancel (MoveToManager::PerformMovement re-cancels anyway, §3a). |
|
||||||
|
| P2 | **`get_desired_heading` (0x0052aad0) 180/0 constants.** BN body is an x87 setcc garble (decomp §5e); ACE gives `WalkForward\|RunForward → movingAway ? 180 : 0`, `WalkBackwards → movingAway ? 0 : 180`, default 0. The command GROUPING is visible in the BN text; the constants are not. | decomp §5e vs ACE MovementParameters.cs:186-198 | One Ghidra MCP decompile of `0x0052aad0` (`/decompile_function?address=0x0052aad0`) when a CodeBrowser is up (was down during extraction — decomp §10). Confidence already high; port ACE's shape if Ghidra stays down, with the pin noted UNVERIFIED-BY-GHIDRA in the pseudocode doc. |
|
||||||
|
| P3 | **`heading_diff` (0x00528fb0) third-arg mirror — the two research docs CONTRADICT.** r4-moveto-decomp §5g: "arg3 (the turn command) is UNUSED in the body — keep the parameter for signature parity". r4-ace-moveto §1: ACE has `if (result > EPSILON && motion != TurnRight) result = 360 - result` and says the mirror is "invisible in the pseudo-C — verify once in Ghidra". Behavior-bearing: `HandleTurnToHeading` @0052a1bc passes the LIVE `current_command` (can be TurnLeft) into heading_diff for its progress test — with the mirror, a TurnLeft turn measures progress as 360−raw; without it, the progress window `(ε, 180)` reads the wrong side and TurnLeft turns would increment fail_progress_count every tick (harmless in retail — the counter is dead — but the `previous_heading` update path differs). | decomp §5g vs ACE MoveToManager.cs:817-828 | Ghidra decompile of `0x00528fb0` — decisive (the fn is 40 lines). If Ghidra stays down: cdb bp on `acclient!heading_diff` logging arg3+ST0 during a TurnLeft-direction TurnToHeading. Do not port either reading unpinned. |
|
||||||
|
| P4 | **TargetManager scope — what feeds `HandleUpdateTarget` for type 6/8.** Retail: `set_target` registers the mover as a voyeur on the target (radius 0.5, quantum 0); the target's movement fans back via `receive_target_update` → `HandleUpdateTarget` (decomp §9f; ACE `TargetManager.cs` with a 0.5 s `HandleTargetting` cadence + 10 s timeout). The r4 extraction covers only the CALL SHAPES — TargetManager bodies were NOT extracted. Without SOME feed, `initialized` never flips and type-6/8 moves never start (UseTime gate §6a). | decomp §9f (shapes only) vs ACE TargetManager.cs (full impl, server-side) | DECISION (made here, confirm in V0): R4 ships a **minimal App-side `TargetTracker` adapter**, not a TargetManager port: on `set_target(0, tlid, 0.5, 0)` GameWindow's entity table delivers one immediate `TargetInfo{ObjectId, Ok, target_position=entity pos, interpolated_position=same}`, then re-delivers per tick when the target has moved > 0.5 units since last delivery (the voyeur radius from the call site); `clear_target` unsubscribes; despawn/teleport of the target delivers `status=ExitWorld/Teleported` (→ CancelMoveTo 0x37/0x38 falls out of the verbatim manager). `set_target_quantum` is accepted and recorded but does NOT throttle the tracker (retail uses it to slow updates; over-delivery is convergence-safe). Register row (AP class) replacing AP-8's "no target re-tracking" clause; the full TargetManager port is R5 scope alongside PositionManager. If V0 finds this too loose, the fallback pin is extracting `TargetManager::SetTarget/ReceiveUpdate/HandleTargetting` from the raw (grep `TargetManager::` — est. ~200 lines) and porting verbatim in V4 instead. |
|
||||||
|
| P5 | **Position::heading convention + coordinate space.** MoveToManager consumes `Position::heading(from, to)` (degrees), `Frame::get_heading/set_heading`, `Position::distance`, `Position::cylinder_distance`. acdream has NO Core heading helper (only `SceneryHelpers.SetHeading`); RemoteMoveToDriver derives yaw from quaternions ad hoc. Wire `DesiredHeading` is degrees in AC convention. The manager will run in acdream's streaming-world space (Vector3 + cell id) via `OriginToWorld`, not retail's block-local Position — distances are equivalent (Euclidean after rebase); headings must match the wire convention exactly or `use_final_heading` faces the wrong way. | acclient Position::heading (not in this extraction); `RemoteMoveToDriver.OriginToWorld` :263-277; L.2b outbound heading packers (already convert yaw→AC heading) | Grep the named raw for `Position::heading` + `Frame::get_heading` and pin the formula (expected: heading = fmod(450 − atan2-degrees, 360) or the L.2b-established equivalent — the outbound AP packer already encodes body yaw→AC heading; REUSE that exact conversion, don't derive a second one). Golden test: heading(a,b) for the four cardinal offsets == {0, 90, 180, 270} per the wire convention ACE round-trips. |
|
||||||
|
| P6 | **TurnToObject (mt 8) wire layout.** Decomp §2f case 8 reads `object_id` dword, then a `wire_heading` dword, THEN UnPackNet's 3 dwords (bitfield, speed, desired_heading); on unresolvable object it substitutes `desired_heading = wire_heading` and degrades to TurnToHeading. Two heading fields exist on the wire; ACE's server-side writer is the cheap cross-check for which is which. | decomp §2f @300653-300676; ACE MovementEvents TurnToObject writer | Read ACE's `MoveToObject/TurnToObject` GameMessage writers (Network/GameMessages) and byte-diff against the decomp read order; build the V3 parse fixtures from ACE's writer directly (golden bytes). |
|
||||||
|
| P7 | **Remote contact for the UseTime gate.** `MoveToManager::UseTime` gates on `transient_state & 1` (CONTACT). Remotes' `PhysicsObj.TransientState` is maintained well enough that R3's funnel `contact_allows_move` works for remote Falling dispatch — but confirm the Contact bit specifically is live for grounded NPCs (not just OnWalkable), else remote movetos stall forever. | MotionInterpreter.cs:1979/2089 (funnel reads Contact+OnWalkable for remotes today) | Code read of the remote PhysicsBody state writers + one `ACDREAM_DUMP_MOTION` smoke: a chasing NPC must pass the gate every grounded tick. If remotes lack the bit, fixing the STATE WRITER is in scope (root cause), not softening the gate. |
|
||||||
|
|
||||||
|
**V0 also commits the research docs into the repo**
|
||||||
|
(`docs/research/2026-07-03-r4-movetomanager/`: the decomp extraction, the ACE
|
||||||
|
cross-ref, this plan, and a `V0-pins.md` in the W0-pins format), and fixes the
|
||||||
|
two known stale comments so nobody ports against them: RemoteMoveToDriver.cs:53-57
|
||||||
|
("ACE swaps the chase/flee arrival predicates" — WRONG, refuted in r4-ace-moveto §1;
|
||||||
|
the file dies in V4 but V0-readers must not absorb the claim) — a doc-only touch.
|
||||||
|
|
||||||
|
**V0 cdb capture (optional, non-blocking):** all seven pins are textually
|
||||||
|
resolvable (P1 needs an ACE trace, not retail cdb). If a retail session happens
|
||||||
|
anyway: bp `MoveToManager::PerformMovement` / `BeginMoveForward` /
|
||||||
|
`BeginTurnToHeading` / `HandleMoveToPosition` / `HandleUpdateTarget` /
|
||||||
|
`MovementParameters::get_command` (args+ret) while the user runs: use a door at
|
||||||
|
range (MoveToObject + arrival), use while facing away (TurnToHeading node first),
|
||||||
|
let a monster chase (aux-turn band), outrun it (fail-distance NOT firing — dead
|
||||||
|
counter), /follow-style sticky if reachable. Feeds V2/V4 goldens; synthetic +
|
||||||
|
ACE-writer fixtures suffice without it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. ITEMIZED GAPS — current vs retail (R4 scope)
|
||||||
|
|
||||||
|
Severity: **BLOCKER** = stage deliverable impossible without it; **HIGH** =
|
||||||
|
visible behavior wrongness / blocks R5-R6; **MED** = edge-visible; **LOW** = textual.
|
||||||
|
|
||||||
|
| # | Retail behavior acdream lacks/diverges on | Retail anchor | Current-code anchor | Severity |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| M1 | **No MoveToManager exists** — no state block (movement_type, sought/current_target/starting positions, params copy, progress clocks, current/aux command, moving_away, initialized), no `pending_actions` node queue, none of the 33 members. THREE independent approximations stand in for the one retail mechanism: `DriveServerAutoWalk` (local player), `RemoteMoveToDriver.Drive` (remotes), `ServerControlledLocomotion.PlanMoveToStart` (animation seed). | struct acclient.h:31473; whole extraction §§1-7 | RemoteMoveToDriver.cs; PlayerMovementController.cs:567-766; ServerControlledLocomotion.cs:28-53; GameWindow.cs:4412/4942 | **BLOCKER** |
|
||||||
|
| M2 | **Command-selection family absent**: `get_command` (0x0052aa00 — move/flee band pick + THE walk-vs-run rule: CanCharge 0x10 fast-path → Run, else CanRun && (!CanWalk \|\| dist−dto > threshhold 15) → Run else walk), `towards_and_away` (0x0052a9a0 — WalkBackwards inside the min band), `get_desired_heading` (0x0052aad0, P2). B.6's one-shot wire-CanCharge decision (#77) approximates the fast-path only — no per-tick re-evaluation, so a running auto-walk never demotes to walk-pace near the target (the R3 visual-pass expected-diff "auto-walk-at-run walk-pace legs (R4)"). `PlanMoveToStart` has NO distance logic at all. | decomp §5c/§5d/§5e | PlayerMovementController.cs:452-487 (`_autoWalkInitiallyRunning` one-shot), :701-706; ServerControlledLocomotion.cs:35-53 | **BLOCKER** |
|
||||||
|
| M3 | **No node plan** (`[TurnToHeading(face)] → [MoveToPosition] → [TurnToHeading(final)]?` with `BeginNextNode` stepping): auto-walk's turn-first phase + 30° walk-while-turning band are invented substitutes (AD-26's 5°/30°); the `use_final_heading` (0x40) node is entirely missing — wire `DesiredHeading` is parsed and thrown away. | §3c/§4a/§4b/§6f | PlayerMovementController.cs:606-706; CreateObject.MoveToPathData.DesiredHeading unused (r4-ace §5 wire layer) | **BLOCKER** |
|
||||||
|
| M4 | **No aux-command steering**: retail corrects heading DURING the move by issuing TurnRight/TurnLeft as interpreted motions through `_DoMotion` when \|diff\| leaves the ±20° deadband, and STOPS them re-entering it — observers see actual turn cycles. acdream rotates remotes by quaternion math (`RemoteMoveToDriver` π/2 rad/s + the borrowed ACE snap) and the local player via yaw writes — no turn motions dispatched, wrong legs. | HandleMoveToPosition Phase 1 @307225-307280 (20/340 deadband) | RemoteMoveToDriver.cs:68 (snap tolerance), :236 (ACE set_heading fudge); PlayerMovementController.cs:658-672 | **HIGH** |
|
||||||
|
| M5 | **Arrival predicate approximated + wrong distance metric**: retail `GetCurrentDistance` uses `cylinder_distance(own r/h, target r/h)` when `use_spheres` (0x400 — set in every ACE MoveTo packet) — edge-to-edge; acdream measures center-distance and compensates with AD-8's `max(minDistance, distanceToObject)` hack + an invented `ArrivalEpsilon 0.05` + the AD-26 facing gate. Retail: `moving_away ? dist ≥ min_distance : dist ≤ distance_to_object`, distance-only. | GetCurrentDistance §5a; arrival @307306-307331 (adjudicated r4-ace §1) | RemoteMoveToDriver.cs:161 (AD-8), :170-254; PlayerMovementController.cs:606-620 (AD-26 gate at :618) | **HIGH** |
|
||||||
|
| M6 | **No progress detector / fail distance**: `CheckProgressMade` (1 s window, 0.25 units/s incremental AND overall), `fail_distance` → CancelMoveTo(0x3D YouChargedTooFar), the FLT_MAX progress-clock reseeds on retarget. acdream substitutes the AD-9 1.5 s staleness timer (a liveness guard retail doesn't have) and parses FailDistance into `MoveToPathData` without a consumer. | CheckProgressMade §5b; @307300-307331; HandleUpdateTarget retarget @307843-307861 | RemoteMoveToDriver.cs:136 (AD-9); CreateObject.cs MoveToPathData.FailDistance unused | **HIGH** |
|
||||||
|
| M7 | **Types 8/9 (TurnToObject/TurnToHeading) dropped end-to-end** — the wire parser handles mt 6/7 only (mt 8/9 fall through to an empty Parsed: heading + params silently discarded); no consumer exists. These are the plan-of-record's "dropped D9/DEV-5 commands". | unpack §2f cases 8/9; UnPackNet 0xc-byte form §2g | UpdateMotion.cs:252 (`movementType is 6 or 7` — no 8/9 branch) | **HIGH** |
|
||||||
|
| M8 | **Target re-tracking absent** (type 6/8 initialization + retarget): no set_target/HandleUpdateTarget chain; the wire target guid (mt 6) is parsed into `MoveToPathData.TargetGuid` and never used — acdream chases the packet-time ORIGIN and relies on ACE's ~1 Hz MoveTo re-emit for freshness. Also no quantum retune (Phase 3, dist/speed ETA). | HandleUpdateTarget §6d; MoveToObject §3b; Phase 3 @307378-307436; §9f seams | RemoteMoveToDriver.cs:44-51 (doc'd skip = AP-8); UpdateMotion.cs:313 (guid parsed, unused) | **HIGH** (P4 pins the R4 answer) |
|
||||||
|
| M9 | **TS-36 dangling**: `InterruptCurrentMovement` is a no-op Action — `jump()` (:921/:1833 sites), `StopCompletely` (:1062), and DoMotion/StopMotion's cancel_moveto bit interrupt NOTHING. Once a real moveto exists, a jump or user input mid-moveto would leave the moveto running alongside — the exact "silent double-motion" the TS-36 row predicts. | interrupt_current_movement §9e → MovementManager::CancelMoveTo §2c | MotionInterpreter.cs:645/:921/:998/:1062/:1833; register TS-36 | **BLOCKER** for V5 |
|
||||||
|
| M10 | **PerformMovement discipline absent**: every new moveto must CancelMoveTo(0x36) + `unstick_from_object` FIRST (§3a); MoveToObject/MoveToPosition StopCompletely unconditionally, TurnTo\* only on the stop_completely bit (0x10000); self-target → CleanUp+Stop degenerate. acdream's `BeginServerAutoWalk` just overwrites fields. | PerformMovement §3a; §3b-§3e | PlayerMovementController.cs:452-487 | **HIGH** |
|
||||||
|
| M11 | **MovementType/MovementStruct too narrow**: enum lacks `Invalid=0, MoveToObject=6, MoveToPosition=7, TurnToObject=8, TurnToHeading=9`; MovementStruct lacks `ObjectId/TopLevelId/Pos/Radius/Height/Params`. | MovementTypes acclient.h:2856; MovementStruct acclient.h:38069 | MotionInterpreter.cs:101-113 (1-5 only), :408-422 | MED (mechanical) |
|
||||||
|
| M12 | **WeenieError lacks the moveto codes**: 0x0B NoMotionInterpreter, 0x36 ActionCancelled, 0x37 ObjectGone, 0x38 NoObject, 0x3D YouChargedTooFar (0x47 exists). Local-only, safe add. | codes §7c/§7a; A10 table | MotionInterpreter.cs:136-205 | LOW |
|
||||||
|
| M13 | **`my_run_rate` not fed from the MoveTo wire**: retail unpack cases 6/7 write `minterp->my_run_rate = read_float()`; acdream parses `MoveToRunRate` but only feeds the PlanMoveToStart seed — the interp's `MyRunRate` (:585) never sees it, so `apply_run_to_command`'s speed scale during a moveto uses stale rate. | unpack @300603/@300660 | UpdateMotion.cs:342; GameWindow.cs:4415 | MED |
|
||||||
|
| M14 | **Sticky discipline absent**: MoveToPosition/TurnToHeading must CLEAR sticky (0x80) on the stored params (`&= 0xffffff7f`); BeginNextNode's empty-queue sticky branch hands off to `PositionManager::StickTo(tlid, radius, height)` — reads the three fields BEFORE CleanUp zeroes them. R4 ships the seam (Action, no-op → R5 StickyManager) + the verbatim ordering. Wire side: the 0xF74C header sticky bit (motionFlags & 0x1 → trailing sticky guid dword) is parsed by nobody (`_motionFlags` dead at UpdateMotion.cs:135). | §3c @0052a3e5; §3e @0052a70c; BeginNextNode §4b; unpack case 0 @0052455d | nothing; UpdateMotion.cs:135 | MED (seam now, body R5) |
|
||||||
|
| M15 | **Heading/geometry helpers absent in Core**: `Position::heading`, `heading_diff` (P3), `heading_greater`, `Frame::get/set_heading`, `cylinder_distance`. Local yaw↔AC-heading conversion exists only in the outbound packers and ad-hoc quaternion math. | §5f/§5g/§5a; P5 | SceneryHelpers.cs:80 (render-side only); RemoteMoveToDriver quaternion yaw | **BLOCKER** (mechanical) |
|
||||||
|
| M16 | **`PlanFromVelocity` invented heuristics carry NO register row** (StopSpeed 0.20, RunThreshold 1.25 — acdream constants for the UP-dead-reckoning cycle pick, GameWindow.cs:4942/:5350 "until S6"). Found during this audit: a divergence without a row is a register-rule violation regardless of R4. Its CONSUMER (UP-DR cycle selection) is R6 scope, so the code survives R4 — the row must land now. | none (that's the problem) | ServerControlledLocomotion.cs:54-87; GameWindow.cs:4937-4943, :5350 | MED (bookkeeping, fix in V4) |
|
||||||
|
| M17 | **Speculative use-turn bypasses the (future) manager**: `InstallSpeculativeTurnToTarget` (GameWindow.cs:12122) locally predicts ACE's MoveTo before the wire packet via the auto-walk machinery + the 7.5 m CanCharge guess + AP-23 radius buckets. Retail's client-side use flow issues local `CPhysicsObj::TurnToObject/MoveToObject` through the SAME manager (§9a/§9b callers). | §9a/§9b | GameWindow.cs:11915/:12004/:12122-12164 | MED (rewire in V5) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. KEEP LIST — already matching retail (do not re-port)
|
||||||
|
|
||||||
|
| Behavior | Retail anchor | acdream anchor |
|
||||||
|
|---|---|---|
|
||||||
|
| `MovementParameters` flags+scalars class: A4 masks, ctor defaults 0x1EE0F / dto 0.6 / threshhold 15 / FLT_MAX / speed 1 / HoldKey.Invalid, CanCharge-false + threshold-15 ACE-trap notes | ctor 0x00524380; W0-pins A4 | `Motion/MovementParameters.cs` — V1 EXTENDS with get_command family; no field changes |
|
||||||
|
| The entire `_DoMotion`/`_StopMotion` seam target: `adjust_motion(ref,ref,HoldKey)` + `DoInterpretedMotion`/`StopInterpretedMotion(uint, MovementParameters)` incl. the retail double-adjust (MoveToManager adjusts, DoInterpretedMotion adjusts again — r4-ace §1 "do NOT fix") | §7a/§7b; 0x00529010/0x00529080 | MotionInterpreter.cs:1241/:2861/:3016 — untouched |
|
||||||
|
| WeenieError numerics 0x8/0x24/0x3f-0x49/0x47 (R3-W1 renumber) — V1 only ADDS values | A10 table | MotionInterpreter.cs:136-205 |
|
||||||
|
| mt 6/7 wire parse: UnPackNet field order (bitfield, dto, min, fail, speed, threshhold, desiredHeading) + runRate tail + mt-6 guid head — verified against §2g this session | UnPackNet 0x0052ac50 §2g | UpdateMotion.cs:280-356 + `CreateObject.MoveToPathData` — V3 adds mt 8/9 beside it |
|
||||||
|
| `OriginToWorld` (landblock-local origin → streaming world) | Position rebase equivalence (P5) | RemoteMoveToDriver.cs:263-277 — MOVES to the manager's home in V4 (the one survivor of the file) |
|
||||||
|
| MotionSequenceGate (S1) in front of ALL 0xF74C routing — R4 changes nothing upstream of the gate | 0xF74C dispatch pc:357214 | GameWindow OnLiveMotionUpdated head |
|
||||||
|
| R2 pipeline (MotionTableManager/GetObjectSequence/funnel sink) — MoveToManager's dispatched motions flow through it like any other interpreted motion; the animation side needs ZERO new work | R2/R3 shipped | Motion/ classes + MotionTableDispatchSink |
|
||||||
|
| Contact/OnWalkable transient-state plumbing (local + remote) — UseTime's `transient_state & 1` gate reads the same source `contact_allows_move` uses (P7 confirms) | @307781 | MotionInterpreter.cs:1979/2089 pattern; PhysicsBody TransientState |
|
||||||
|
| HitGround/LeaveGround verbatim bodies (R3-W4) — V4/V5 add the moveto HitGround call BESIDE interp.HitGround (retail order: minterp first, then moveto, §2d); LeaveGround has NO moveto side (COMDAT no-op, §2e) | MovementManager::HitGround 0x00524300 | MotionInterpreter.cs HitGround/LeaveGround + their App call sites |
|
||||||
|
| Jump charge UI (AP-24), run/jump skill defaults (TS-21), AP diff cadence (AP-30), outbound packers — untouched by R4 | — | PlayerMovementController.cs sections 4-8 |
|
||||||
|
| AD-27 Use/PickUp re-send on arrival (ACE MoveToChain-timeout workaround) — SURVIVES, re-anchored to the new `MoveToComplete` seam (§4); retail notifies nothing on arrival (CleanUpAndCallWeenie is CleanUp+Stop only, §7e) | §7e | GameWindow.cs:11915 subscription; register AD-27 |
|
||||||
|
| AP-23 per-type use-radius heuristic — survives (ACE's close-branch broadcasts nothing actionable; unchanged by R4), re-anchored | ACE Player_Move.cs:66 | GameWindow.cs:12102-12164; register AP-23 |
|
||||||
|
| `PlanFromVelocity` (UP-DR cycle heuristic) — survives to R6 with its NEW register row (M16); only `PlanMoveToStart` dies in R4 | retire R6 (per-tick order) | ServerControlledLocomotion.cs:54-87 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. COMMIT SEQUENCE — dependency-sorted, each ONE commit, tests-first
|
||||||
|
|
||||||
|
New code target: `src/AcDream.Core/Physics/Motion/MoveToManager.cs` (+
|
||||||
|
`MoveToMath.cs` for the free functions) per plan rule 4 — pure logic, GL-free,
|
||||||
|
seams only. NAME WATCH: retail's `MoveToManager::MovementNode {type, heading}`
|
||||||
|
must NOT collide with R2's `Motion/MotionNode.cs` (the pending_motions node) —
|
||||||
|
name it `MoveToNode`, register-note the rename. Tests:
|
||||||
|
`tests/AcDream.Core.Tests/Physics/Motion/`. Every commit: build+test green,
|
||||||
|
register rows added/retired in-commit.
|
||||||
|
|
||||||
|
**V0 — pins + research docs (docs only).**
|
||||||
|
Commit `docs/research/2026-07-03-r4-movetomanager/` (decomp extraction, ACE
|
||||||
|
cross-ref, this plan, `V0-pins.md` resolving P1-P7 in the W0-pins format —
|
||||||
|
P1's ACE trace + source read and P3's Ghidra decompile are load-bearing; P2/P5/P6
|
||||||
|
are cheap; P4 is a recorded decision). Fix the RemoteMoveToDriver.cs:53-57 stale
|
||||||
|
claim in the same commit. Deps: none.
|
||||||
|
|
||||||
|
**V1 — command-selection family + state widening (Core, no consumers).** (closes M2-mechanics, M11, M12, M15)
|
||||||
|
`MovementParameters` gains `GetCommand(dist, heading, out motion, out holdKey,
|
||||||
|
out movingAway)` (verbatim §5c INCLUDING the CanCharge 0x10 fast-path ACE dropped
|
||||||
|
— A13+A15 trap: retail's version of BOTH), `TowardsAndAway` (§5d),
|
||||||
|
`GetDesiredHeading` (P2 pin), plus a `FromWire(uint bitfield, dto, min, fail,
|
||||||
|
speed, threshhold, desiredHeading)` factory (UnPackNet semantics — bit masks per
|
||||||
|
A4) and the field-by-field copy the entry points do. `MoveToMath.cs`: `HeadingDiff`
|
||||||
|
(P3 pin), `HeadingGreater` (§5f), `PositionHeading` + `GetHeading/SetHeading`
|
||||||
|
(P5 pin — REUSING the outbound packer's yaw↔heading conversion, one convention
|
||||||
|
in the codebase), `CylinderDistance` (§5a signature). `MovementType` gains
|
||||||
|
Invalid/6/7/8/9; `MovementStruct` gains `ObjectId/TopLevelId/Pos(world+cell)/
|
||||||
|
Radius/Height/Params`. `WeenieError` += 0x0B/0x36/0x37/0x38/0x3D.
|
||||||
|
Tests first: get_command truth table (all four flag quadrants × distance bands ×
|
||||||
|
the hold-key cascade incl. CanCharge fast-path, threshold-edge ≤ vs <,
|
||||||
|
walk-incapable), towards_and_away three bands, heading helpers wrap/epsilon
|
||||||
|
(0.000199999995f literal) tables, cardinal-heading goldens (P5).
|
||||||
|
Fixture source: **synthetic + A4/A10 pins**. Deps: V0.
|
||||||
|
|
||||||
|
**V2 — MoveToManager verbatim (Core class + conformance harness; no App wiring).** (closes M1/M3/M4/M5/M6/M10/M14-core)
|
||||||
|
All 33 members per the extraction: ctor/Create/InitializeLocalVariables (flags
|
||||||
|
word + context_id zeroed, floats stale, FLT_MAX distance resets — NOT ACE's A2/A3)
|
||||||
|
/Destroy; PerformMovement (cancel 0x36 + unstick first, 4-way); MoveToObject/
|
||||||
|
MoveToPosition/TurnToObject (desired-heading clobber quirk VERBATIM)/TurnToHeading
|
||||||
|
(immediate BeginNextNode — ACE's A4 gap not copied); MoveToObject_Internal/
|
||||||
|
TurnToObject_Internal (fmod, sought-heading read); node factories + `MoveToNode`
|
||||||
|
(managed List<> — AD-34 wording); BeginNextNode (sticky handoff reads
|
||||||
|
radius/height/tlid BEFORE CleanUp, StickTo seam no-op); BeginMoveForward
|
||||||
|
(localParams: clear 0x8000, holdKey from get_command, write-back to stored
|
||||||
|
params, progress-clock seed); BeginTurnToHeading (empty-head → CancelMoveTo(8)
|
||||||
|
per A10 — not ACE's throw; PreviousHeading := DIFF quirk verbatim);
|
||||||
|
UseTime (retail gate polarity: `tlid == 0 || type == Invalid || initialized` —
|
||||||
|
NOT ACE's A12 negation); HandleMoveToPosition (Phase 1 aux 20°/340° — NO ACE A6
|
||||||
|
set_heading snap, NO A8 inRange block; Phase 2 arrival `moving_away ? dist ≥ min
|
||||||
|
: dist ≤ dto`, fail-distance 0x3D; Phase 3 quantum ETA retune via seam);
|
||||||
|
HandleTurnToHeading (heading_greater → snap set_heading(node.Heading, send:true)
|
||||||
|
+ pop + stop + next); HandleUpdateTarget (init/retarget split, 0x37/0x38 codes,
|
||||||
|
FLT_MAX reseeds); HitGround; CheckProgressMade (1 s / 0.25 both-rates);
|
||||||
|
GetCurrentDistance (use_spheres → cylinder); CleanUp (stop current+aux,
|
||||||
|
clear_target gate, InitializeLocalVariables — does NOT drain nodes);
|
||||||
|
CleanUpAndCallWeenie (CleanUp THEN StopCompletely — reentrancy-safe ordering
|
||||||
|
+ the `MoveToComplete` seam, §4); CancelMoveTo (drain + CleanUp + Stop; the
|
||||||
|
WeenieError arg kept-but-unread per §7c); _DoMotion/_StopMotion (§7a — adjust
|
||||||
|
then dispatch); is_moving_to; fail_progress_count as a write-only field (§8).
|
||||||
|
Seams (ctor-injected, §4): interp, StopCompletely, position/heading accessors,
|
||||||
|
own+target radius/height, IsInterpolating, Contact, set_target/clear_target/
|
||||||
|
quantum, unstick, StickTo, MoveToComplete.
|
||||||
|
Tests first: per-function conformance tables from the extraction's constants
|
||||||
|
inventory (§12); the reentrancy test (CancelMoveTo → CleanUpAndCallWeenie →
|
||||||
|
interp.StopCompletely → InterruptCurrentMovement → CancelMoveTo no-ops on
|
||||||
|
Invalid); quirk goldens (TurnToObject heading clobber ⇒ final = face-object;
|
||||||
|
BeginTurnToHeading stores diff; UseTime gate matrix incl. uninitialized type-6
|
||||||
|
stall); a scripted end-to-end table drive (positions fed per tick → expected
|
||||||
|
node pops + dispatched motion ids/hold keys, incl. run→walk demote inside
|
||||||
|
threshold 15).
|
||||||
|
Fixture source: **synthetic + §12 constants (+ V0 cdb goldens if captured)**.
|
||||||
|
Deps: V1.
|
||||||
|
|
||||||
|
**V3 — wire completion: mt 8/9 + my_run_rate + params exposure.** (closes M7, M13, M14-wire-note)
|
||||||
|
UpdateMotion parses mt 8 (guid + wire_heading + 3-dword UnPackNet, P6 order) and
|
||||||
|
mt 9 (3-dword UnPackNet) into a widened `MoveToPathData` (or a sibling
|
||||||
|
`TurnToPathData`) carrying the DECODED bitfield; mt 6/7 exposure widened so ALL
|
||||||
|
UnPackNet fields reach the consumer as a `MovementParameters` via `FromWire`
|
||||||
|
(today only ad-hoc bit properties). Parse the 0xF74C motionFlags sticky-guid
|
||||||
|
trailer (bit 0x1 → read dword; carried, unconsumed until R5) so the buffer
|
||||||
|
cursor is honest; standing_longjump bit (0x2) NOTED as an R5 unpack_movement
|
||||||
|
item, not consumed here. `MoveToRunRate` documented as the `MyRunRate` write
|
||||||
|
the V4/V5 consumers perform (unpack @300603).
|
||||||
|
Tests first: golden-byte fixtures generated from ACE's event writers (P6) for
|
||||||
|
all four types + flag permutations; existing mt 6/7 fixtures green unchanged.
|
||||||
|
Fixture source: **ACE-writer golden bytes**. Deps: V1 (types), parallel with V2.
|
||||||
|
|
||||||
|
**V4 — REMOTE cutover: per-remote MoveToManager; RemoteMoveToDriver + PlanMoveToStart DELETED.** (closes M1-remote, M4/M5/M6/M8-remote; retires AD-8, AD-9, AP-8, AP-9; adds the M16 row)
|
||||||
|
Each `RemoteMotion` gains a `MoveTo` manager bound to its existing
|
||||||
|
`Motion` interp + body (construction beside the R3 sink bind, §4). GameWindow
|
||||||
|
remote UM routing (:4549-4620): mt 6 → resolve guid against the entity table →
|
||||||
|
`MovementStruct{MoveToObject, radius/height from the entity's setup}` →
|
||||||
|
`MoveTo.PerformMovement`; unresolvable → degrade to MoveToPosition(wire origin)
|
||||||
|
per §2f; mt 7/8/9 likewise; `Motion.MyRunRate = MoveToRunRate`. The P4
|
||||||
|
`TargetTracker` adapter feeds `HandleUpdateTarget` (register row). Per-tick: the
|
||||||
|
:9594-9682 block becomes `rm.MoveTo.UseTime()` (same slot the legacy driver
|
||||||
|
occupied — see the placement decision below); remote HitGround sites add
|
||||||
|
`rm.MoveTo.HitGround()` after `rm.Motion.HitGround()` (§2d order). DELETE:
|
||||||
|
`RemoteMoveToDriver.cs` (OriginToWorld moves to `MoveToMath`; TurnRateFor's
|
||||||
|
surviving consumers, if any outside the deleted paths, get the constant from the
|
||||||
|
interp's own apply_run_to_command home), `PlanMoveToStart` + its :4412 seeding
|
||||||
|
branch (the manager's own `_DoMotion` → funnel sink now produces the cycle,
|
||||||
|
identical mechanism to every other interpreted motion), the
|
||||||
|
`HasMoveToDestination/MoveToDestinationWorld/LastMoveToPacketTime/
|
||||||
|
ServerMoveToActive` RemoteMotion fields and their :4917/:5329 reads.
|
||||||
|
Registers in-commit: AD-8/AD-9/AP-8/AP-9 DELETED; NEW AP row "TargetTracker
|
||||||
|
minimal re-tracking adapter (P4) — full TargetManager port R5"; NEW AP row for
|
||||||
|
PlanFromVelocity's constants (M16, survives to R6); note that arrival now uses
|
||||||
|
retail cylinder distance (the AD-8 max() class is GONE — watch melee-range stop
|
||||||
|
distance in the visual pass).
|
||||||
|
Tests first: scripted chase/flee/retarget/fail-distance scenario harness driving
|
||||||
|
a manager against a mocked tracker; dispatched-motion trace conformance (NPC
|
||||||
|
chase emits WalkForward+HoldKey_Run → aux turns → stop, per V2's table);
|
||||||
|
existing remote funnel suites green. Live smoke: NPC chase + ACDREAM_DUMP_MOTION.
|
||||||
|
Fixture source: **V2 harness + live smoke**. Deps: V2+V3.
|
||||||
|
|
||||||
|
**V5 — LOCAL PLAYER cutover: B.6 auto-walk DELETED; TS-36 bound (ONE commit, GameWindow + controller — do NOT fan out, feedback_dont_parallelize_coupled_plan_slices).** (closes M1-local, M9, M10, M17; retires TS-36, AD-26; re-anchors AD-27, AP-23; adds the P1 row if pinned as an adaptation)
|
||||||
|
`PlayerMovementController` gains a `MoveTo` manager bound to its `Motion` interp
|
||||||
|
(exposed like `Motion` was for R3-W2's bind). GameWindow local routing
|
||||||
|
(:4507-4547): the `BeginServerAutoWalk` call becomes MovementStruct →
|
||||||
|
`MoveTo.PerformMovement` (OriginToWorld unchanged); the P1 pin governs the mt-0
|
||||||
|
companion echo (adaptation row if that's the pin). `Motion.InterruptCurrentMovement`
|
||||||
|
binds to `MoveTo.CancelMoveTo(ActionCancelled)` — TS-36 RETIRED; user input now
|
||||||
|
cancels the moveto exactly the retail way (input edge → DoMotion with
|
||||||
|
CancelMoveTo bit → interrupt seam → CancelMoveTo), so the explicit
|
||||||
|
"user-input cancel" check dies with the block. DELETE the whole B.6 block:
|
||||||
|
fields :275-:340 (incl. `IsServerAutoWalking`, `_autoWalkTurnDirectionThisFrame`
|
||||||
|
+ its :1528-1540 consumer), `BeginServerAutoWalk` :452, `EndServerAutoWalk` :495,
|
||||||
|
`DriveServerAutoWalk` :567-766, the :896 call + `autoWalkConsumedMotion` skip
|
||||||
|
plumbing (:1028). `AutoWalkArrived` is replaced by the `MoveToComplete(None)`
|
||||||
|
seam subscription (AD-27 re-anchored, same Use-resend behavior).
|
||||||
|
`InstallSpeculativeTurnToTarget` (:12122) rewires to a LOCAL
|
||||||
|
`MovementStruct{TurnToObject/MoveToObject}` through the player's manager
|
||||||
|
(retail §9a/§9b client-initiated shape; AP-23 buckets survive as the radius
|
||||||
|
source, row re-anchored). Per-tick: `MoveTo.UseTime()` at the :896 slot
|
||||||
|
(placement decision below). `[autowalk-*]` probes retarget to the manager
|
||||||
|
(PhysicsDiagnostics name kept).
|
||||||
|
Tests first: full suite green; controller edge-driven suites unchanged;
|
||||||
|
manager-driven use-walk scenario (turn node → walk → demote-to-walk near target
|
||||||
|
→ arrival → MoveToComplete fires once); reentrancy live test (jump mid-moveto
|
||||||
|
cancels it — TS-36's predicted failure); outbound golden-byte parity for a
|
||||||
|
scripted approach (MTS/AP bytes vs pre-cutover capture — the moveto issues
|
||||||
|
non-autonomous motions, so outbound autonomous traffic must NOT change).
|
||||||
|
**ONE user visual pass** (with R2/R3's if still pending): use a door at range
|
||||||
|
(walk-up + arrival + door opens once), use while facing away (visible turn
|
||||||
|
first), NPC chase legs (turn cycles during corrections, walk-pace close-in),
|
||||||
|
TurnTo emote-target if reachable.
|
||||||
|
Fixture source: **pre-cutover traces + golden-byte + V2 harness**. Deps: V4.
|
||||||
|
|
||||||
|
**V6 — register sweep + roadmap + digest (docs/cleanup only).**
|
||||||
|
Verify retired: AD-8/AD-9/AP-8/AP-9 (V4), TS-36/AD-26 (V5). Verify added:
|
||||||
|
TargetTracker AP row, PlanFromVelocity AP row (M16), P1 echo AD row (if pinned),
|
||||||
|
MoveToNode-rename note under AD-34's wording, StickTo/quantum no-op seam rows
|
||||||
|
(→R5). Re-anchor: AD-25 (:1212 shifts after the controller deletion), AD-27,
|
||||||
|
AP-23, AP-24, TS-21, AP-30 line numbers. Roadmap stage table (R4 shipped);
|
||||||
|
milestones check; memory digest note (animation deep-dive: MoveToManager gap
|
||||||
|
CLOSED; the "three approximations" pattern retired).
|
||||||
|
Deps: V5.
|
||||||
|
|
||||||
|
Parallelization: V0∥nothing; V1→V2 sequential; V3∥V2 (after V1). V4→V5
|
||||||
|
sequential (remotes prove the manager before the local cutover). V4 and V5 both
|
||||||
|
touch GameWindow — single-agent each.
|
||||||
|
|
||||||
|
**DECISION — B.6 auto-walk dies in R4 (V5), not R5.** Grounds: the plan of
|
||||||
|
record's R3 keep-list marked it "REPLACED in R4 by MoveToManager"; the mandate
|
||||||
|
is delete-in-stage; everything DriveServerAutoWalk does is a subset of the
|
||||||
|
verbatim manager (turn-first = TurnToHeading node; 30° walk-while-turning band =
|
||||||
|
retail's 20° aux band; arrival = the distance predicate; user cancel = the
|
||||||
|
cancel_moveto interrupt chain); keeping it alongside a real manager would mean
|
||||||
|
two writers on the same motion channels — the exact double-motion hazard TS-36
|
||||||
|
warns about. What survives R5-ward is only the two seams the manager can't fill
|
||||||
|
yet: StickTo (R5 StickyManager) and the full TargetManager (P4 row).
|
||||||
|
|
||||||
|
**DECISION — per-tick driver placement pre-R6: the legacy drivers' own slots.**
|
||||||
|
`MoveTo.UseTime()` runs (a) for the player: in `PlayerMovementController.Update`
|
||||||
|
at the exact point `DriveServerAutoWalk` runs today (:896, before the
|
||||||
|
input-driven motion block); (b) for remotes: in the GameWindow per-remote tick
|
||||||
|
block where `RemoteMoveToDriver.Drive` runs today (:9594). Justification:
|
||||||
|
least-invasive — both slots already sit at the "after inbound wire, before/with
|
||||||
|
physics integrate" altitude the legacy approximations were tuned for, so the
|
||||||
|
cutover changes WHAT steers, not WHEN, and every behavior diff in the visual
|
||||||
|
pass is attributable to the manager itself rather than to a reordering. Retail's
|
||||||
|
true slot (`UpdateObjectInternal`: … transition sweep → MovementManager.UseTime →
|
||||||
|
PositionManager.UseTime, plan module map) is R6's deliverable for ALL managers
|
||||||
|
at once; R3 set the precedent (r3-port-plan §4 rule 2: "tick placement
|
||||||
|
provisional until R6"). The provisional placement gets a one-line note in both
|
||||||
|
call sites, not a register row (ordering-within-tick is R6's audited scope).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. WIRING CONTRACT — MoveToManager ↔ the R3 MotionInterpreter (+ GameWindow routing)
|
||||||
|
|
||||||
|
Retail chain being stood in for (R5 inserts MovementManager as a null-guarded
|
||||||
|
relay WITHOUT behavior change — same rule as R3 §4's MotionDone note):
|
||||||
|
|
||||||
|
```
|
||||||
|
retail: unpack_movement / CPhysicsObj entry points
|
||||||
|
└─ MovementManager::PerformMovement (1-5 → CMotionInterp, 6-9 → MoveToManager)
|
||||||
|
└─ MoveToManager ── _DoMotion/_StopMotion ──► CMotionInterp
|
||||||
|
(adjust_motion → DoInterpretedMotion/StopInterpretedMotion)
|
||||||
|
R4: GameWindow OnLiveMotionUpdated (mt switch — the pre-existing call-site dispatch)
|
||||||
|
├─ mt 0 → funnel (unchanged; P1 pin governs the local-player echo)
|
||||||
|
├─ mt 1-5 → interp paths (unchanged)
|
||||||
|
└─ mt 6/7/8/9 → entity.MoveTo.PerformMovement(MovementStruct) [+ MyRunRate write]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Construction & binding (per entity, at the same site as the R3 sink bind):**
|
||||||
|
- Remote: where `RemoteMotion.Motion` + the MotionTableManager sink are wired
|
||||||
|
(GameWindow entity creation), construct `MoveTo = new MoveToManager(...)` with:
|
||||||
|
- `interp` = `remoteMot.Motion` (the `_DoMotion` target — adjust_motion +
|
||||||
|
DoInterpretedMotion/StopInterpretedMotion, NOTHING else; MoveToManager never
|
||||||
|
calls DoMotion/set_hold_run/raw-state APIs — decomp §7b),
|
||||||
|
- `stopCompletely` = `() => Motion.StopCompletely()` (retail routes
|
||||||
|
CPhysicsObj::StopCompletely → MovementStruct type 5 → interp; the direct
|
||||||
|
call is the same body pre-R5),
|
||||||
|
- body accessors: position+cell, `GetHeading`/`SetHeading(deg, send)` (P5
|
||||||
|
convention; `send` flags the outbound heading snap — remotes: no-op send),
|
||||||
|
own radius/height (setup shape), Contact (`PhysicsObj.TransientState`),
|
||||||
|
`IsInterpolating` = InterpolationManager queue-non-empty,
|
||||||
|
- target seams: `set_target/clear_target` → the P4 TargetTracker;
|
||||||
|
`get/set_target_quantum` → tracker-recorded value (accepted, non-throttling);
|
||||||
|
- `unstick` → `Motion.UnstickFromObject` (existing no-op seam, R5);
|
||||||
|
`stickTo(tlid, radius, height)` → NEW no-op Action seam (R5 StickyManager;
|
||||||
|
register row);
|
||||||
|
- `MoveToComplete(WeenieError)` → remote: nothing (retail-faithful);
|
||||||
|
local player: the AD-27 Use-resend subscription (fires ONLY on `None`).
|
||||||
|
- Local player: `PlayerMovementController` constructs/exposes `MoveTo` bound to
|
||||||
|
its own `Motion`; GameWindow routes local mt 6-9 to it.
|
||||||
|
|
||||||
|
**The cancel chain (TS-36 retires here):**
|
||||||
|
```
|
||||||
|
user input edge / jump() / StopCompletely
|
||||||
|
└─ MotionInterpreter — params.CancelMoveTo bit / hardcoded sites (:921/:998/:1062/:1833)
|
||||||
|
└─ InterruptCurrentMovement?.Invoke() [R3 seam, was no-op]
|
||||||
|
└─ R4 bind: entity.MoveTo.CancelMoveTo(WeenieError.ActionCancelled)
|
||||||
|
└─ drain nodes → CleanUp (stops current+aux via _StopMotion,
|
||||||
|
clear_target, InitializeLocalVariables → movement_type=Invalid)
|
||||||
|
→ StopCompletely
|
||||||
|
```
|
||||||
|
Reentrancy invariant (MUST be tested, V2): the tail `StopCompletely` re-enters
|
||||||
|
`InterruptCurrentMovement` → `CancelMoveTo`, which NO-OPS because CleanUp already
|
||||||
|
reset `movement_type` to Invalid BEFORE the stop (retail ordering §7e; ACE note
|
||||||
|
r4-ace §3). Do not reorder CleanUp/StopCompletely.
|
||||||
|
|
||||||
|
**HandleUpdateTarget feed (P4):** TargetTracker (App) watches the entity table;
|
||||||
|
delivery → `entity.MoveTo.HandleUpdateTarget(TargetInfo)`; context 0 only
|
||||||
|
(retail CPhysicsObj::HandleUpdateTarget @280794 gates on context_id == 0).
|
||||||
|
Target despawn → `status = ExitWorld` (manager cancels 0x37/0x38 itself).
|
||||||
|
|
||||||
|
**Per-tick + ground events:** `MoveTo.UseTime()` at the two legacy slots
|
||||||
|
(decision above). Every existing `Motion.HitGround()` call site adds
|
||||||
|
`MoveTo.HitGround()` AFTER it (retail §2d order: minterp then moveto).
|
||||||
|
LeaveGround/ReportExhaustion: NO moveto call — COMDAT no-ops (§2e), do not
|
||||||
|
invent.
|
||||||
|
|
||||||
|
**GameWindow mt 6-9 routing detail (V4/V5):**
|
||||||
|
1. MotionSequenceGate first (unchanged).
|
||||||
|
2. `Motion.MyRunRate = MoveToRunRate` (unpack @300603/@300660).
|
||||||
|
3. mt 6: resolve `TargetGuid` in the entity table → top-level parent id (retail
|
||||||
|
resolves `parent ?: target`, §9a — acdream: container/parent link if the
|
||||||
|
entity model has one, else the guid itself) + setup radius/height →
|
||||||
|
`MovementStruct{MoveToObject, ObjectId, TopLevelId, Radius, Height,
|
||||||
|
Params=FromWire(...)}`. Unresolvable → `MovementStruct{MoveToPosition,
|
||||||
|
Pos=OriginToWorld(wire origin)}` (the §2f degrade — NOT an error).
|
||||||
|
4. mt 7: MoveToPosition(OriginToWorld(origin), FromWire params).
|
||||||
|
5. mt 8: resolve → TurnToObject; unresolvable → params.DesiredHeading =
|
||||||
|
wire_heading, TurnToHeading (§2f fallback).
|
||||||
|
6. mt 9: TurnToHeading(FromWire 3-field params).
|
||||||
|
7. mt 0 for the local player: per the P1 pin (echo suppression rule or wire
|
||||||
|
discriminator); for remotes: unchanged funnel + (retail head shape) an
|
||||||
|
interrupt — remotes' UM streams from ACE already interleave moveto and
|
||||||
|
interpreted UMs, and V4 must apply the SAME P1 answer to remotes chasing
|
||||||
|
under ACE's re-emit (a fresh mt-6 re-emit cancels+restarts via
|
||||||
|
PerformMovement regardless, so remotes are insensitive to the pin either way).
|
||||||
|
|
||||||
|
**What the manager dispatches (animation side — zero new work):**
|
||||||
|
`_DoMotion(0x45000005 WalkForward, localParams{holdKey})` → adjust_motion
|
||||||
|
promotes to RunForward×runRate under HoldKey_Run → DoInterpretedMotion → the
|
||||||
|
R2 sink → MotionTableManager → GetObjectSequence — the identical path a wire
|
||||||
|
RunForward takes today. `PlanMoveToStart`'s seeding job ceases to exist rather
|
||||||
|
than being replaced.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. NEGATIVE RESULTS / DO-NOT-INVENT (binding on subagents)
|
||||||
|
|
||||||
|
- **`MoveToManager::LeaveGround` / `ReportExhaustion` — COMDAT-folded no-ops**
|
||||||
|
in this build (decomp §2e); `CMotionInterp::HandleEnterWorld` likewise. No
|
||||||
|
members, no seams, no behavior.
|
||||||
|
- **`fail_progress_count` is write-only in RETAIL too** (§8: 6 sites, zero
|
||||||
|
reads). NO give-up-after-N mechanism exists — the only aborts are
|
||||||
|
fail_distance (0x3D) and target status (0x37/0x38). Do not invent a stall
|
||||||
|
timeout to replace AD-9's timer; deleting the timer is the point.
|
||||||
|
- **`CancelMoveTo`'s WeenieError arg is NEVER READ in the body** (§7c) — every
|
||||||
|
call site's code (8/0x36/0x37/0x38/0x3D) is dead in this build. Keep the
|
||||||
|
parameter (R5 parity + logging), wire no behavior to its value.
|
||||||
|
- **`CleanUpAndCallWeenie` contains NO weenie call in this build** (§7e — the
|
||||||
|
name is vestigial; body ≡ CleanUp + StopCompletely). acdream's
|
||||||
|
`MoveToComplete` seam is a documented CLIENT addition standing in for ACE's
|
||||||
|
server-side `OnMoveComplete` — do not present it as retail.
|
||||||
|
- **No listener/observer machinery on MoveToManager or MovementManager**
|
||||||
|
(grep-negative, §10). `MoveToComplete` is the one seam, added consciously.
|
||||||
|
- **`CPhysicsObj::cancel_moveto` does not exist** — the cancel entry is
|
||||||
|
`interrupt_current_movement` (§9e). **`CPhysicsObj::MoveToPosition` does not
|
||||||
|
exist** — position moves enter via unpack case 7 / direct manager call only.
|
||||||
|
- **Do NOT copy the ACE-isms** (all RETAIL-VERIFIED in r4-ace §4): A1
|
||||||
|
stale-member UseFinalHeading read (read the ARGUMENT in MoveToPosition), A2
|
||||||
|
InitializeLocalVars zeroing DistanceToObject (retail zeroes the FLAGS word),
|
||||||
|
A3 missing FLT_MAX resets, A4 missing BeginNextNode in TurnToHeading, A6
|
||||||
|
set_heading snap in the aligned branch ("custom: sync for server ticrate"),
|
||||||
|
A7 `AlwaysTurn`, A8 `inRange` arrival block ("custom for low monster update
|
||||||
|
rate"), A11 WeenieObj NPE, A12 UseTime gate negation, A13 CanCharge-true
|
||||||
|
default, A14 threshold 1.0, A15 dropped CanCharge fast-path.
|
||||||
|
- **Do NOT "fix" the retail quirks**: TurnToObject's desired_heading write is
|
||||||
|
clobbered before any read (final heading = face-the-object; ACE matches);
|
||||||
|
BeginTurnToHeading stores the remaining DIFF into previous_heading; the
|
||||||
|
double adjust_motion (_DoMotion + DoInterpretedMotion-internal); PerformMovement
|
||||||
|
returning 0 unconditionally (errors surface via CancelMoveTo, not the return).
|
||||||
|
- **HandleTurnToHeading's `set_heading(node.Heading, true)` snap IS retail**
|
||||||
|
(0052a146) — the only heading snap in the whole family. Everything else
|
||||||
|
rotates via dispatched turn motions.
|
||||||
|
- **BN artifact ledger applies** (decomp §11): the chase-arrival and turn-pick
|
||||||
|
branch senses in the raw are `test ah,0x41` inversions — the adjudicated
|
||||||
|
readings (chase arrives at `dist ≤ distance_to_object`; diff ≥ 180 →
|
||||||
|
TurnLeft) are pinned; do not re-derive from the literal pseudo-C. The
|
||||||
|
`RemoteMoveToDriver.cs:53-57` "ACE swaps the predicates" doc claim is WRONG
|
||||||
|
(superseded in-file at :186-199) — fixed in V0.
|
||||||
|
- **TargetManager / StickyManager / ConstraintManager / PositionManager::StickTo
|
||||||
|
bodies were NOT extracted** — call shapes only (§9f/§9g). Do not invent
|
||||||
|
internals beyond the P4 minimal adapter; R5 owns the ports.
|
||||||
|
- **The older Ghidra chunk `chunk_00520000.c` is from a DIFFERENT build** for
|
||||||
|
this address region (§10) — function boundaries don't align; unusable for
|
||||||
|
adjudication. Use the named raw + Ghidra MCP on `patchmem.gpr` only.
|
||||||
|
- **MovementManager itself is R5** — R4 must not grow a premature facade; the
|
||||||
|
type dispatch stays at the existing GameWindow/controller call sites, and the
|
||||||
|
two direct binds (interrupt→CancelMoveTo, HitGround dual-call) are exactly
|
||||||
|
the relays R5 will absorb.
|
||||||
Loading…
Add table
Add a link
Reference in a new issue