acdream/docs/research/2026-07-03-r4-moveto/V0-pins.md
Erik 386b1ce550 docs(R4-V0): pins P1-P7 resolved — the autonomous-byte gate IS retail's echo discriminator; heading_diff mirror Ghidra-confirmed
P1 (BLOCKER -> RESOLVED, no adaptation needed): retail's
CPhysics::SetObjectMovement (0x00509690) drops any movement event whose
wire AUTONOMOUS byte is set when the addressed object IsThePlayer -
BEFORE unpack_movement's unconditional head-interrupt ever runs. ACE's
mt-0 MoveToState echo is IsAutonomous=1 HARDCODED (MovementData.cs:162)
and only exists when the client itself sends a MoveToState (the
2026-05-14 'echo kills auto-walk' trace was the pre-#75 build whose
overlay leaked outbound packets - post-#75 there is no echo at moveto
start). acdream parses isAutonomous (UpdateMotion.cs:129) and consumes
it NOWHERE; MotionSequenceGate cites 0x00509690 in its own doc but
omitted this final branch. V-commits port the missing gate tail and the
verbatim head-cancel lands cleanly; the informal GameWindow
no-cancel-on-non-MoveTo note retires.

P3 (Ghidra-confirmed live, textual verdict independently identical):
heading_diff 0x00528fb0 HAS the TurnLeft mirror - 360-diff whenever the
turn command != TurnRight; the extraction's 'arg unused' was a BN
x87-setcc artifact (the W6b class). P2 Ghidra-confirmed:
get_desired_heading = fwd-towards 0 / fwd-away 180 / back-towards
180 / back-away 0. ghidra-confirmations.md holds both raw decompiles.
P4-P7 pinned per V0-pins.md. RemoteMoveToDriver's stale chase/flee
class-doc claim corrected (retail and ACE AGREE on the predicates).

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

21 KiB
Raw Blame History

R4-V0 — ambiguity pins and ACE-oddity adjudications

The verbatim extraction is r4-moveto-decomp.md (line-anchored); the port plan with the full ambiguity table is r4-port-plan.md §0. This note records the PINNED resolutions the V1V6 ports code against. Every pin below was produced by an independent raw re-read of docs/research/named-retail/acclient_2013_pseudo_c.txt (line numbers cited are that file's), cross-read against ACE source (references/ACE/Source/ACE.Server/, MoveToManager under Physics/Managers/), and P1/P3 additionally survived an adversarial refutation pass (P3 by direct byte-level disassembly of the PDB-matched retail binary — evidence one tier stronger than a Ghidra decompile). No pin was refuted; none blocks on cdb or Ghidra. Ghidra MCP was down for the whole pass; the deferred decompile checks are listed per-pin and in the tail section — all confirmatory, none load-bearing.

Pinned

  • P1 — ACE's companion mt-0 echo vs retail's unpack-head cancel: PINNED — no adaptation needed; port the missing TAIL of retail's own gate. CPhysics::SetObjectMovement (0x00509690) drops any movement event whose wire autonomous byte is set when the addressed object IsThePlayer, BEFORE unpack_movement is ever called — so retail's unconditional head-interrupt never sees an autonomous echo. Plan option (c) (suppress-when-command-matches) is DISCARDED. Confidence: high (adversarially verified; refutation failed on every angle).

    Port shape: in the local-player 0xF74C path, after the S1 MotionSequenceGate passes, drop the event entirely — no interrupt, no state application — when update.IsAutonomous && guid == local player guid. Verbatim (raw 271370271431): ebx = weenie_obj->vtable->IsThePlayer() @005096a8; two wraparound sequence gates; then @0050972e if ((arg7 == 0 || ebx == 0)) { arg2->last_move_was_autonomous = arg7; CPhysicsObj::unpack_movement(arg2, &arg3, arg4); if (ebx != 0) return 1; } else fall to return 0. Note last_move_was_autonomous is stored ONLY on the unpack path — do not store it for dropped events. The ||-vs-&& BN-inversion worry is refuted behaviorally: with && the local player would never unpack ANY movement event, yet server MoveTos demonstrably drive retail clients against ACE. The 4-arg overload (0x00509790, raw 271458271485) pins arg7 = the wire byte at offset +4 after two u16 sequences with align-to-4 — byte-identical to ACE's writer (MovementData.cs:189-200: GetNextSequence(ObjectMovement); autonomous ? GetCurrentSequence : GetNextSequence(ObjectServerControl); Convert.ToByte(motion.IsAutonomous); writer.Align()).

    With the gate in place, the verbatim unpack_movement head-cancel ports cleanly: CPhysicsObj::interrupt_current_movement + unstick_from_object on EVERY UM type 0/6/7/8/9 before the type switch (0x00524440, raw 300566300573; jump table @300707300719 — types 15 fall to return 0, head already ran). interrupt_current_movement = if (movement_manager) MovementManager::CancelMoveTo(0x36) (raw 278189278200), a no-op when moveto_manager == 0 (raw 300277300288 @0x005241b0). mt 6/7/8/9 keep the retail unconditional cancel as planned (PerformMovement re-cancels anyway, §3a).

    Why the echo was never a retail problem: the "companion mt-0 echo" is NOT unconditional ACE behavior — it is ACE reflecting the client's OWN outbound MoveToState back to the sender. BroadcastMovement has exactly ONE caller (GameActionMoveToState.cs:36); MovementData(Creature, MoveToState) hardcodes IsAutonomous = true (MovementData.cs:162) and Player_Networking.cs:365 sends it to self (EnqueueBroadcast(true, movementEvent); ACE's own comment "shouldn't need to go to originating player?"). The 2026-05-14 trace behind GameWindow.cs:4534-4546 was self-inflicted: the pre-#75 auto-walk synthesized Forward+Run input that leaked outbound as a MoveToState (PlayerMovementController.cs:546-557 preserves the finding); post-#75 (:298-308) no MoveToState is built during auto-walk, so no echo exists at moveto start — the GameWindow.cs:4534-4546 causal story ("ACE follows every mt=0x06 with an mt=0x00") is stale. ACE's mt-6/7/8/9 senders are IsAutonomous = false (Motion.cs:18 default, copied at MovementData.cs:44; only two IsAutonomous = true sites in ACE.Server: MovementData.cs:162 and the Player.cs:944-951 jump hack), so they pass the gate and the auto-walk trigger survives. The gate also absorbs ACE's other self-addressed autonomous mt-0 class (the charged-jump hack) — and it is precisely why retail WASD against ACE (every keypress echoed back IsAutonomous=1) doesn't stutter: standing behavioral corroboration.

    Register adjudication (the exact wording V5/V6 execute): NO new AD row. The informal "do NOT cancel local moveto on a non-MoveTo UM" adaptation at GameWindow.cs:4534-4546 has no register row (verified — a row-less deviation) and is RETIRED by the ported retail mechanism; delete the comment block with the code in V5. ONE CONTINGENCY: the gate severs acdream's only live runSkill sync feed — GameWindow.cs:4364-4372 wires ApplyServerRunRate(update.MotionState.ForwardSpeed) and its ONLY live source is the autonomous mt-0 echo. V5 must re-anchor run-rate sync to retail's own mechanism (mt-6/7 my_run_rate wire read = plan item M13, plus PlayerDescription). If V5 instead taps the echo's ForwardSpeed BEFORE the drop, that tap IS an ACE-compat adaptation requiring this row in the same commit:

    AD-new | Local-player autonomous-echo ForwardSpeed tap — acdream reads InterpretedMotionState.ForwardSpeed from the self-addressed autonomous mt-0 UM (ACE's MoveToState reflection, MovementData.cs:162 IsAutonomous=true hardcoded) before the ported retail SetObjectMovement autonomous/IsThePlayer drop (0x00509690 @0050972e) discards it, feeding PlayerMovementController.ApplyServerRunRate. Retail discards the event entirely. | Risk: local/observer run-speed desync if the echo changes; masks the missing my_run_rate wire feed. | Retire when M13's mt-6/7 my_run_rate + PlayerDescription feed lands.

    Adjacent seam (R5 note): retail's 0xF74C dispatch calls cmdinterp->vtable->LoseControlToServer() whenever SetObjectMovement returns nonzero — i.e. whenever a NON-autonomous movement event is applied to the local player (raw 357214357235: instance-seq gate then if (CPhysics::SetObjectMovement(...) != 0) this->cmdinterp->vtable-> LoseControlToServer();). This is the retail autonomy-handoff hook that BeginServerAutoWalk currently approximates — R5/MovementManager scope.

    Ghidra deferred (non-blocking): decompile 0x00509690 (re-verify the (arg7 == 0 || ebx == 0) condition + which sequence stamps are written on the reject paths — the function has BN self-subtraction flag-test garbles at @005096db/@00509712, but the decisive gate is a plain integer test with the adjacent last_move_was_autonomous store); decompile the 0xF74C dispatch case at ~0x00559850 (confirm LoseControlToServer fires iff SetObjectMovement != 0).

  • P2 — get_desired_heading (0x0052aad0) 180/0 constants: PINNED to ACE's shape. RunForward|WalkForward → movingAway ? 180 : 0; WalkBackwards → movingAway ? 0 : 180; default 0. Confidence: high. The BN raw (308016308033) confirms only the command GROUPING — {RunForward 0x44000007, WalkForward 0x45000005} vs {WalkBackwards 0x45000006} vs default — while both constant branches render identically (result = arg3) and the default renders as x87 flag-synthesis nonsense (return (arg2 - 0x45000006)), the known setcc-garble class. Constants + per-branch polarity are 100% ACE-sourced (MovementParameters.cs:186-198) but doubly corroborated: (a) caller math (raw 307227307233, HandleMoveToPosition @0x00529e25) adds the return to Position::heading(to target) then wraps [0,360) — a degree OFFSET; (b) physics: §5c pure-away picks WalkForward+movingAway=1 ("heading flips via get_desired_heading +180", r4-moveto-decomp.md:619-620) → away-walk faces away = +180; §5d towards_and_away picks WalkBackwards+movingAway=1 inside the min band ("backs up with WalkBackwards (no turn)", :656) → backstep faces the target = 0. Ghidra deferred (non-blocking): decompile 0x0052aad0; mark the pseudocode doc UNVERIFIED-BY-GHIDRA until then.

  • P3 — heading_diff (0x00528fb0) third-arg mirror: PINNED as MIRROR PRESENT — port ACE's body verbatim. result = h1 h2; if |result| < ε result = 0; if result < −ε result += 360; if (result > ε && motion != TurnRight) result = 360 result; with ε = 0.000199999995f and TurnRight = 0x6500000D. r4-moveto-decomp §5g's "arg3 UNUSED in the body" is a BN x87-setcc artifact (the W6b class); r4-ace-moveto §1's suspicion was correct. Confidence: high — sealed by direct disassembly of the PDB-matched binary (C:\Turbine\Asheron's Call\acclient.exe, CodeView GUID 9e847e2f-777c-4bd9-886c-22256bb87f32 = MATCH), which supersedes the deferred Ghidra check entirely. The tail BN garbled into a fake "return flag word" (raw 306344306346) disassembles as exactly the mirror guard:

    00528fec  fcom  dword [0x007c8a00]      ; result vs ε (0.00019999...)
    00528ff2  fnstsw ax
    00528ff4  test  ah, 0x41                ; C0|C3 → result <= ε
    00528ff7  jnz   0x00529009              ; skip mirror unless result > ε STRICT
    00528ff9  cmp   dword [esp+0xC], 0x6500000D   ; arg3 vs TurnRight — arg3 IS read
    00529001  je    0x00529009              ; TurnRight → skip mirror
    00529003  fsubr dword [0x0079bc60]      ; ST0 = 360.0f  result
    00529009  ret                           ; float return in ST0
    

    The 29 bytes BN collapsed (0x00528fec→0x00529008) fit the mirror encoding to the byte; test ah,0x41 / jnz encodes strict > (skip on less OR equal), so ACE's > comparison (Managers/MoveToManager.cs:825-826) is the correct port. Call sites: BeginTurnToHeading @0x00529be0 passes CONSTANT 0x6500000d TurnRight (mirror explicitly disabled — direction pick stays the ≤180 test); HandleTurnToHeading @0x0052a1bc (raw :307495) passes the LIVE current_command. Two refinements over the plan's P3 row: (1) the pin's "previous_heading update path differs" clause is FALSE — both branches of the progress test write previous_heading identically (raw @0x0052a1ef / @0x0052a206; ACE :611-621); (2) the mirror is behaviorally DEAD in this build — its only divergent effect is fail_progress_count reset-vs-increment, and that counter is write-only (6 refs in the whole raw, all writes: =0 @0x005292b1/@0x00529f3e/@0x0052a13a/@0x0052a1e5, +=1 @0x0052a014/@0x0052a224; zero reads) — port it for verbatim fidelity + conformance tests, not because retail-visible behavior hangs on it. No deferred check remains (Ghidra + cdb fallback both MOOT).

  • P4 — TargetManager scope: DECISION CONFIRMED — R4 ships the minimal App-side TargetTracker adapter, not a TargetManager port. Confidence: high (as a recorded decision; the retail feed constants and delivery contract are pinned from the extraction). On set_target(0, tlid, 0.5, 0) (verbatim call sites: r4-moveto-decomp.md:359 MoveToObject §3b, :423 TurnToObject §3d; constants table :1168 — radius 0.5f, initial quantum 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; clear_target unsubscribes; target despawn/teleport delivers status = ExitWorld/Teleported so CancelMoveTo 0x37/0x38 falls out of the verbatim manager (§6d @307802307867: != Ok on first callback → 0x38 NoObject :857-858; on retarget → 0x37 ObjectGone :866-867; retarget sets sought_position = interpolated, current_target_position = target :869-870 + FLT_MAX progress reseeds :871-874). set_target_quantum is accepted and recorded but does NOT throttle (over-delivery is convergence-safe). TargetStatus values from ACE (r4-ace-moveto.md:654-656): Undefined=0, OK=1, ExitWorld=2, Teleported=3, Contained=4, Parented=5, TimedOut=6; ACE's HandleTargetting cadence is 0.5 s with a 10 s Undefined→TimedOut timeout. Wire inputs available today: mt-6 TargetGuid (UpdateMotion.cs:310-315, parsed-but-unused = gap M8 — the subscription key); wire Origin (:319-326) doubles as the first-delivery fallback when the guid is unresolvable, mirroring retail's own case-6 degrade to MoveToPosition(wire origin) (r4-moveto-decomp.md:290-292). NOT on the wire: target radius/height for cylinder_distance (from the local entity table) and ongoing target updates (the adapter's per-tick tracking; ACE's ~1 Hz MoveTo re-emit is the backstop). Register row (AP class) lands in V4, replacing AP-8's "no target re-tracking" clause; full TargetManager port is R5 scope. Fallback if the adapter proves too loose in V4: grep TargetManager:: in the raw (~200 lines) and port verbatim.

  • P5 — Position::heading convention: PINNED — compass degrees, 0 = North (+Y), 90 = East (+X), CLOCKWISE, [0,360); identity quaternion faces heading 0. Formula: heading(from, to) = (450 atan2Deg(dy, dx)) % 360. Wire DesiredHeading is in THIS convention (retail @0052a71d feeds it straight into Frame::set_heading on sought_position, §3e). Golden cardinals: N(0,+1)→0, E(+1,0)→90, S(0,1)→180, W(1,0)→270. Confidence: high. Evidence: Position::heading 0x005a9520 (raw 438288438290): 450.0 fpatan(...) × 57.295779513082323, fmod tail; ACE Position.cs:200-209 pins the atan2 arg order (Atan2(dir.Y, dir.X)); Frame::get_heading 0x00535760 (raw 319781) same formula on the forward basis (ACE AFrame.cs:147-152); Frame::set_heading 0x00535e40 (raw 320055320066) round-trips — h=0 → forward (sin 0, cos 0) = +Y. An in-tree twin of the formula already exists at SceneryHelpers.cs:75.

    ⚠ CORRECTION to the plan's own pin method — the packer-reuse trap. The plan says "the outbound AP packer already encodes body yaw→AC heading; REUSE that exact conversion". Reuse it at the QUATERNION level ONLY (YawToAcQuaternion/AcQuaternionToYaw are wire-correct end-to-end — its two internal errors cancel). Its internal SCALAR intermediate (GameWindow.cs:8083-8103: headingDeg = 180f yawDeg) is holtburger's convention = retail + 90°, and its comment "AC heading: 0=West, 90=North, 180=East, 270=South" (from holtburger math.rs:56, whose identity-quat note :146-148 says "Heading 90 (North)") is WRONG as a statement of the retail wire convention. Literal scalar reuse would face use_final_heading and every get_desired_heading offset 90° wrong. The correct scalar bridge from acdream yaw (yaw=0 faces +X per PlayerMovementController.cs:1022-1025) is heading = (90 yawDeg) mod 360. Quaternion-level proof: YawToAcQuaternion(yaw=0) → (z=0.7071, w=+0.7071), from which ACE's AFrame.get_heading extracts 90 = East — correct for a +X-facing body.

  • P6 — TurnToObject (mt 8) wire layout: PINNED byte-for-byte from ACE's writer, matching the decomp read order exactly. After the common UM header (u8 movementType, u8 motionFlags, u16 currentStyle — MovementData.cs:202-205): [u32 object guid][f32 standalone heading][u32 params bitfield][f32 speed] [f32 params desired_heading]. mt 9 (TurnToHeading) = the 3-dword TurnToParameters only. Confidence: high. The standalone second field is ACE TurnToObject.DesiredHeading = retail's wire_heading, consumed ONLY in the unresolvable-object fallback: substituted into params.desired_heading, then degrade to TurnToHeading (decomp §2f case 8, r4-moveto-decomp.md:293-298: object_id = read_dword(); wire_heading = read_dword(); UnPackNet(...); if (GetObjectA(object_id) == 0) { var_9c.desired_heading = wire_heading; goto label_524725; }; ACE TurnToObject.cs:25-31 + field comment :10-11 "used instead of the DesiredHeading in the TurnToParameters"). UnPackNet 0xc-byte form per §2g (:311): bitfield, speed, desired_heading. Gap M7 confirmed: UpdateMotion.cs:252 else if (movementType is 6 or 7) — mt 8/9 payloads silently discarded today. V3 fixture caveat: ACE populates BOTH heading fields from the same source (TurnToObject.cs:17 and TurnToParameters.cs:18 both copy motion.DesiredHeading), so ACE-golden fixtures always have field2 == field5 — distinguish the two fields by OFFSET (hand-vary one in synthetic fixtures), never by value.

  • P7 — remote Contact for the UseTime gate: PINNED — Contact IS live for grounded remotes; no state-writer fix needed. Confidence: high. Remote bodies are BORN with Contact | OnWalkable | Active (GameWindow.cs:580-591) and BOTH remote per-tick paths re-assert TransientState |= Contact | OnWalkable | Active unconditionally every tick while !rm.Airborne (:9366-9370 player-remote pipeline; :9570-9574 NPC/legacy — the path RemoteMoveToDriver runs on today). The bits are cleared ONLY for the airborne arc (:4833-4841, Velocity.Z > 0.5f launch) and restored on landing by two independent paths (:5161-5167 UP grounded snap; :9808-9818 resolver landing — which already calls rm.Motion.HitGround(), the exact retail re-arm seam MoveToManager::HitGround §6e binds to: if (movement_type == Invalid) return; BeginNextNode(this);, r4-moveto-decomp.md:884-889). Retail UseTime gates on transient_state & 1 (Contact only, @307781) — a strict SUBSET of the funnel's Contact && OnWalkable that demonstrably works for remote Falling dispatch today (MotionInterpreter.cs:2089-2092). A chasing grounded NPC passes the gate every tick; an airborne remote stalls the moveto until landing — retail behavior by design. The pin-method's live ACDREAM_DUMP_MOTION smoke was NOT run (read-only pass; the write is unconditional so the smoke is confirmatory only) — folded into V4's acceptance run (already listed there: "Live smoke: NPC chase + ACDREAM_DUMP_MOTION").

Pending

None. No pin was refuted or left uncorrectable.

Deferred non-blocking checks (Ghidra MCP down during V0)

All three are confirmatory — the textual adjudications above are decisive.

Address Function What to confirm
0x00509690 CPhysics::SetObjectMovement (7-arg) The (arg7 == 0 || ebx == 0) gate condition; which sequence stamps are written on the reject paths (BN garbles @005096db/@00509712 are self-subtraction flag-test artifacts)
~0x00559850 0xF74C dispatch case LoseControlToServer() fires iff SetObjectMovement != 0
0x0052aad0 MovementParameters::get_desired_heading The 180/0 constants + per-branch polarity (currently ACE-sourced; grouping BN-verified)

P3's deferred check (0x00528fb0) is MOOT — resolved by direct disassembly of the PDB-matched binary (see the pin).

Adjacent findings (load-bearing for V-commits, discovered while pinning)

  • runSkill-sync severance (V5 trap, from P1): GameWindow.cs:4364-4372's ApplyServerRunRate(ForwardSpeed) is fed EXCLUSIVELY by the autonomous mt-0 echo the P1 gate will drop. V5 re-anchors run-rate sync to M13 (mt-6/7 my_run_rate wire read → Motion.MyRunRate) + PlayerDescription — or ships the contingent AD row quoted in P1.
  • LoseControlToServer seam (R5): retail hands autonomy to the server at the 0xF74C dispatch whenever a non-autonomous movement event is applied to the local player (raw 357214357235). Note for R5/MovementManager — the retail hook BeginServerAutoWalk approximates today.
  • GameWindow.cs:4534-4546 comment is stale (from P1): the "ACE follows every mt=0x06 with an mt=0x00" causal story described the pre-#75 build's self-inflicted echo; post-#75 no echo exists at moveto start. The comment block dies with the code in V5 — do not port against it.
  • RemoteMoveToDriver.cs:53-57 stale claim FIXED in this commit (plan §0 mandate): the class doc claimed "ACE swaps the chase/flee arrival predicates" — refuted by r4-ace-moveto §1 and by the file's own :186-199 comment; retail and ACE agree (chase arrives dist <= distance_to_object, flee arrives dist >= min_distance).

V0 cdb capture (pending, non-blocking)

No pin above depends on it — P1/P3 were adversarially sealed textually (P3 down to the instruction bytes). One live retail session feeds V2/V4 goldens: bp MoveToManager::PerformMovement / BeginMoveForward / BeginTurnToHeading / HandleMoveToPosition / HandleUpdateTarget / MovementParameters::get_command (args+ret) while the user runs the plan §0 protocol (door-use at range, use-while-facing-away, monster chase, outrun). A heading_diff arg3+ST0 log during a TurnLeft-direction TurnToHeading would spot-check P3's mirror live — nice-to-have only.