acdream/docs/research/2026-07-02-inbound-motion-maps/map-retail-decomp.md
Erik fb3ee0544a docs(L.2g): inbound motion deviation map + campaign registration
/investigate deliverable for the inbound (remote-entity) animation+position
retail-parity effort. 10 deviations (DEV-1..10) mapped and adversarially
verified against the named retail decomp + ACE port + current code (9
confirmed, 1 refuted-and-corrected).

Headline: the #39-era UP-pace->cycle inference layer's premise ('wire goes
silent on Shift toggle') is refuted at both oracles — retail sends a fresh
MoveToState on HoldRun toggle while moving (0x006b37a8) and ACE rebroadcasts
every MoveToState unconditionally (GameActionMoveToState.cs:36); retail has
NO pace->animation adaptation anywhere (position error is absorbed solely by
the InterpolationManager chase, already ported verbatim in L.3).

Registers sub-lane L.2g in the roadmap: port the CMotionInterp inbound funnel
verbatim for all remote entity classes, slices S0-S6.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 15:20:46 +02:00

57 KiB

Retail decomp map: INBOUND remote-entity motion pipeline

Source: docs/research/named-retail/acclient_2013_pseudo_c.txt (66 MB, Sept 2013 EoR build, PDB-named pseudo-C). All line numbers below are LINE NUMBERS in that file (not addresses), from a Sept-2026 checkout at C:/Users/erikn/source/repos/acdream/.claude/worktrees/vigorous-joliot-f0c3ad. Addresses (0x0051xxxx etc.) are also given per function for cross-reference with symbols.json / Ghidra.


Q1 — INBOUND ENTRY: wire message -> motion interpreter

Full call chain, outermost (network) to innermost (motion-table state machine):

ACSmartBox::DispatchSmartBoxEvent(NetBlob*)        line 357117  (0x005595d0)
  switch (opcode) {
    case 0xf619:   // "Movement" — the live/current movement update
      SmartBox::UnpackPositionEvent(...)                          line 357142
      if result == NETBLOB_PROCESSED_OK:
        CPhysicsObj* obj = CObjectMaint::GetObjectA(pObjMaint, guid)
        CPhysics::SetObjectMovement(physics, obj, buf, bufSize)    line 357154 (call), def @271370 (0x00509690)
        if nonzero: cmdinterp->LoseControlToServer()

    case 0xf74c:   // position+movement combo, has an extra u16 seq check first
      is_newer(obj->update_times[8], seq) gate                     line 357224
      CPhysics::SetObjectMovement(physics, obj, buf, bufSize)      line 357232
  }

CPhysics::SetObjectMovement (2-arg overload used for 0xf619/0xf74c dispatch, __stdcall, line 271370 / addr 0x00509690):

int32 SetObjectMovement(CPhysics* this, CPhysicsObj* obj, buf, bufLen,
                         u16 seqA, u16 seqB, bool isAutonomous)
{
    isPlayer = obj->weenie_obj && obj->weenie_obj->IsThePlayer();

    // 16-bit wraparound-aware "is this sequence number newer" compare,
    // done TWICE against two independent counters obj->update_times[1]
    // and obj->update_times[5]:
    diff = |seqA - obj->update_times[1]|          (mod 0x10000)
    newer = (diff > 0x7fff) ? (seqA < old) : (old < seqA)   // wraparound rule
    if not newer: return 0                         // STALE PACKET, DROPPED

    obj->update_times[1] = seqA
    diff2 = |obj->update_times[5] - seqB|
    newer2 = (diff2 > 0x7fff) ? (old < seqB) : (seqB < old)
    if not newer2: return 0                         // STALE, DROPPED
    obj->update_times[5] = seqB

    if (!isAutonomous || !isPlayer) {                // remote entity ALWAYS
                                                       // takes this branch;
                                                       // local player only
                                                       // takes it when NOT
                                                       // self-driving (server
                                                       // override / rubber-band)
        obj->last_move_was_autonomous = isAutonomous
        CPhysicsObj::unpack_movement(obj, &buf, bufLen)   line 271423 (0x00509742)
        if isPlayer: return 1   // signals caller to call LoseControlToServer
    }
    return 0
}

CPhysicsObj::unpack_movement (line 280179, addr 0x00512040):

void unpack_movement(CPhysicsObj* this, buf**, bufLen)
{
    if (this->movement_manager == null)
        this->movement_manager = MovementManager::Create(this, this->weenie_obj)
    MovementManager::unpack_movement(this->movement_manager, buf, bufLen)   line 280202
}

MovementManager::unpack_movement (line 300563, addr 0x00524440) — deserializes the wire struct and dispatches to ONE of 10 sub-cases (command_ids[ecx_4] type tag read from the first u16 in the buffer, case 0..9 via jump table @300707):

switch (type_tag) {
  case 0:  // InterpretedMotionState (RawMotionState command wrapper) -- THIS
           // is the walk/run/turn/sidestep command path used for remote
           // players AND monsters
    InterpretedMotionState::UnPack(&ims, buf, bufLen)          line 300606
    // optional trailing u32 = "sticky" target object id
    MovementManager::move_to_interpreted_state(this, &ims)     line 300618 (0x0052457c)
    if sticky_id != 0: CPhysicsObj::stick_to_object(...)
    motion_interpreter->standing_longjump = (type_tag & 0x200)
    return 1

  case 6:  // MoveToObject
    Position::UnPackOrigin + MovementParameters::UnPackNet(MoveToObject)
    motion_interpreter->my_run_rate = <wire float>
    CPhysicsObj::MoveToObject(physics_obj, target_guid, &params)   line 300644

  case 7:  // MoveToPosition
    similar; CPhysicsObj->my_run_rate set from wire; then
    MoveToManager::MoveToPosition(...)                             line 300659

  case 8:  // TurnToObject
  case 9:  // TurnToHeading
    -> MoveToManager::TurnToHeading / handled via MoveToManager
}

For Q2-Q7 (walk<->run transition on an ALREADY-moving remote entity), case 0 (InterpretedMotionState::UnPack + move_to_interpreted_state) is the relevant path. This is opcode 0xF619/0xF74C's "type 0" sub-message — same struct shape as the client's own RawMotionState/interp state, containing current_style, forward_command, forward_speed, sidestep_command/speed, turn_command/speed, plus a list of pending server "actions" (context_id/action_stamp pairs used for jump-charge/attack acknowledgement, NOT used for normal walk/run).

MovementManager::move_to_interpreted_state (line 300259, addr 0x00524170):

void move_to_interpreted_state(MovementManager* this, InterpretedMotionState* ims)
{
    if (motion_interpreter == null) {
        motion_interpreter = CMotionInterp::Create(physics_obj, weenie_obj)
        CMotionInterp::enter_default_state(motion_interpreter)
    }
    CMotionInterp::move_to_interpreted_state(motion_interpreter, ims)   line 300272
}

CMotionInterp::move_to_interpreted_state (line 305936, addr 0x005289c0) — THE entry point that turns a wire InterpretedMotionState into an actual motion-table transition:

int32 move_to_interpreted_state(CMotionInterp* this, InterpretedMotionState* ims)
{
    if (physics_obj == null) return 0
    this->raw_state.current_style = ims->current_style
    CPhysicsObj::interrupt_current_movement(physics_obj)
    bool wasJumpAllowed = CMotionInterp::motion_allows_jump(this, interpreted_state.forward_command)
    InterpretedMotionState::copy_movement_from(&this->interpreted_state, ims)  // <-- OVERWRITES
                                                                                //     forward/side/turn
                                                                                //     command+speed wholesale,
                                                                                //     line 293301
    CMotionInterp::apply_current_movement(this, /*forceReapply=*/1, /*jumpFlag*/ -(...))  line 305949

    // then replay any queued server "actions" (jump charge etc.) whose
    // action_stamp is newer than server_action_stamp — sequence-wraparound
    // compare identical in shape to the SetObjectMovement 0x7fff test above
    for (action in ims->actions) {
        if (newer(action.stamp, this->server_action_stamp)) {
            this->server_action_stamp = action.stamp
            CMotionInterp::DoInterpretedMotion(this, action.motion, &params)
        }
    }
    return 1
}

KEY: copy_movement_from is a flat field-by-field OVERWRITE of the InterpretedMotionState (forward_command, forward_speed, sidestep_, turn_, current_style) — there is no "diff the old vs new command" step here. The actual "is this the same cycle or a new one" decision happens ONE LEVEL DOWN, inside CMotionTable::GetObjectSequence, when apply_current_movement -> apply_interpreted_movement -> DoInterpretedMotion is called with the new forward_command/forward_speed.


Q2 — TRANSITION: walk<->run while already moving

CMotionInterp::apply_current_movement (line 305838, addr 0x00528870):

void apply_current_movement(CMotionInterp* this, int forceFlag, int jumpFlag)
{
    if (physics_obj == null || !initted) return
    isPlayerOrNoWeenie = (weenie_obj == null) || weenie_obj->IsThePlayer()
    if (isPlayerOrNoWeenie && CPhysicsObj::movement_is_autonomous(physics_obj))
        return apply_raw_movement(this, forceFlag, jumpFlag)   // LOCAL player path
    return apply_interpreted_movement(this, forceFlag, jumpFlag)  // REMOTE entity path
}

movement_is_autonomous just returns physics_obj->last_move_was_autonomous (set by SetObjectMovement above — for a genuinely-remote object this flag is always false relative to the LOCAL viewer, so remote players/monsters always take the apply_interpreted_movement branch.)

CMotionInterp::apply_interpreted_movement (line 305713, addr 0x00528600) — the per-command dispatcher that turns the bookkeeping InterpretedMotionState fields back into individual DoInterpretedMotion calls:

void apply_interpreted_movement(CMotionInterp* this, int a, int b)
{
    if physics_obj == null: return
    if interpreted_state.forward_command == RUN_FORWARD (0x44000007):
        this->my_run_rate = interpreted_state.forward_speed   // caches server-echoed run rate

    DoInterpretedMotion(this, interpreted_state.current_style, {})   // style (stance) first

    if (!contact_allows_move(this, interpreted_state.forward_command)) {
        DoInterpretedMotion(this, MOTION_FALLING /*0x40000015*/, {})
    } else if (standing_longjump) {
        DoInterpretedMotion(this, READY_STANCE /*0x41000003*/, {})
        StopInterpretedMotion(this, LONGJUMP /*0x6500000f*/, {})
    } else {
        DoInterpretedMotion(this, interpreted_state.forward_command, {})    // <-- WALK/RUN COMMAND
        if interpreted_state.sidestep_command == 0:
            StopInterpretedMotion(this, SIDESTEP /*0x6500000f*/, {})
        else:
            DoInterpretedMotion(this, interpreted_state.sidestep_command, {})
    }

    if interpreted_state.turn_command != 0:
        DoInterpretedMotion(this, interpreted_state.turn_command, {})
        return   // early return — no idle-stop check runs this frame
    if (StopInterpretedMotion(physics_obj, TURN /*0x6500000d*/, {}) == 0)
        add_to_queue(this, ctx=0, READY_STANCE, tickCount)
}

So a wire "run instead of walk" update decays into exactly one CMotionInterp::DoInterpretedMotion(this, RUN_FORWARD, {speed = new run speed}) call (or WALK_FORWARD) — a single call with the SAME semantics as the local input path, not a special "speed-changed" fast path at this layer.

CMotionInterp::DoInterpretedMotion (line 305575, addr 0x00528360):

uint32 DoInterpretedMotion(CMotionInterp* this, uint32 motion, MovementParameters* p)
{
    if physics_obj == null: return 8
    if (contact_allows_move(this, motion)) {
        if (standing_longjump && motion in {JUMP-ish set}) goto label_528440 (bail to
                                                              ApplyMotion-only path)
        if motion == 0x40000011 /* some "cancel" motion */:
            CPhysicsObj::RemoveLinkAnimations(physics_obj)      // <-- flush queued link anims
        result = CPhysicsObj::DoInterpretedMotion(physics_obj, motion, p)  // -> CPartArray -> MotionTableManager
        if result == 0:
            jumpAllowed = ...
            add_to_queue(this, p->context_id, motion, jumpAllowed)
            if (flag bit 0x40 of context set): InterpretedMotionState::ApplyMotion(&interpreted_state, motion, p)
    } else if (motion & 0x10000000) == 0:
        label_528440:
        if (flag bit 0x40 set): InterpretedMotionState::ApplyMotion(...)
        result = 0
    else:
        result = 0x24  // motion rejected (e.g. mid-air command not allowed)

    if (physics_obj != null && physics_obj->cell == 0)
        CPhysicsObj::RemoveLinkAnimations(physics_obj)     // detached-from-world guard
    return result
}

CPhysicsObj::DoInterpretedMotion -> CPartArray::DoInterpretedMotion -> packs a MovementStruct{type=InterpretedCommand} and calls MotionTableManager::PerformMovement, which for InterpretedCommand calls:

if (CMotionTable::DoObjectMotion(table, motion, &state, &sequence, speed, &outTicks))
    MotionTableManager::add_to_queue(this, motion, outTicks, sequence)   // queues in
                                                                          // pending_animations
                                                                          // (DIFFERENT list
                                                                          // from CMotionInterp's
                                                                          // pending_motions!)

CMotionTable::DoObjectMotion is a thin wrapper for CMotionTable::GetObjectSequence(table, motion, state, seq, speed, outTicks, /*force*/0).

CMotionTable::GetObjectSequence — THE cycle-swap decision (line 298636, addr 0x00522860)

This is the true state machine that decides append-vs-replace-vs-fast-path. Given new_substate = motion & 0xffffff bucketed by which high bit is set on motion:

Bit 0x40000000 set — a normal "cycle" motion (this is what WALK/RUN commands carry, e.g. 0x44000007 RunForward, 0x45000005 WalkForward):

cycleData = cycles.lookup((style<<16) | (new_substate & 0xffffff))
if cycleData != null && CMotionTable::is_allowed(table, new_substate, cycleData, state):

    // *** THE SAME-CYCLE FAST PATH ***
    if (new_substate == state->substate                       // SAME logical
        && same_sign(new_speed, state->substate_mod)          // command (walk OR run
        && CSequence::has_anims(sequence)) {                  // stays walk, or run stays
                                                                // run — direction unchanged)
                                                                // AND a cycle is already
                                                                // playing
        change_cycle_speed(sequence, cycleData, state->substate_mod, new_speed)  // rescale playback rate
        subtract_motion(sequence, cycleData, state->substate_mod)   // remove OLD velocity contribution
        combine_motion(sequence, cycleData, new_speed)               // add NEW velocity contribution
        state->substate_mod = new_speed
        return 1    // <-- NO new CSequence nodes appended. Same AnimSequenceNode
                     //     keeps playing; only its playback-rate + the CSequence's
                     //     cached velocity/omega vectors change.
    }

    // *** DIFFERENT SUBSTATE (e.g. walk -> run is usually a DIFFERENT
    //     substate id, not same-sign-same-substate) — LINK TRANSITION PATH ***
    linkAnim = CMotionTable::get_link(table, state->style, state->substate,
                                       state->substate_mod, new_substate, new_speed)
    if (linkAnim == null || same_sign(new_speed, state->substate_mod) == 0) {
        // no direct link authored, OR direction reversed: route through the
        // style's registered "default"/rest substate as an intermediate hop
        defaultSubstate = style_defaults[state->style]
        linkAnim  = get_link(style, state->substate, state->substate_mod, defaultSubstate, 1.0)
        linkAnim2 = get_link(style, defaultSubstate, 1.0, new_substate, new_speed)
    }

    CSequence::clear_physics(sequence)          // zero cached velocity/omega — see Q6/Q7
    CSequence::remove_cyclic_anims(sequence)    // drop any still-looping cycle node(s)
    add_motion(sequence, linkAnim,  1.0-or-substate_mod)   // append the transition ("link") anim node(s)
    add_motion(sequence, linkAnim2, new_speed)             // (if double-hop via default state)
    add_motion(sequence, cycleData, new_speed)             // append the NEW cyclic anim, marked cyclic
    state->substate      = new_substate
    state->substate_mod  = new_speed
    CMotionTable::re_modify(table, sequence, state)   // re-apply any active modifiers (e.g. sidestep)
                                                        // on top of the new chain
    *outTicks = cycleData->action_head + linkAnim.num_anims + linkAnim2.num_anims - 1
    return 1

Answering the prompt's explicit sub-question (a): DoInterpretedMotion on a speed change does NOT always reuse the same cycle. It depends on whether the new command maps to the SAME substate id as the currently-playing one:

  • Speed-only change to the SAME substate (e.g. WalkForward speed 0.6 -> WalkForward speed 1.0, or RunForward at any two different forward_speed values) hits the fast path: change_cycle_speed + subtract_motion/ combine_motion — same AnimSequenceNode object, just re-timed and re-weighted. No new node, no restart.
  • Walk<->Run is a substate CHANGE (0x45000005 WalkForward vs 0x44000007 RunForward are different substate ids), so it does NOT hit the fast path. It goes through the link-transition path: get_link looks up an authored transition animation (a short blend clip, e.g. walk-to-run or run-to-walk) between the two substates; that link node(s) are appended to the sequence via add_motion, followed by the new cyclic node. The OLD cyclic node is dropped (remove_cyclic_anims). Playback then proceeds: link anim plays first (non-cyclic, finite frames), and once it completes the CSequence::update_internal advance mechanism moves curr_anim forward in the anim_list to the next node — the new cyclic walk/run anim — automatically (see Q4).

Sub-question (b): the CSequence node list. CSequence::anim_list is a doubly-linked list (DLListBase of AnimSequenceNode), NOT a single "QueuedAnimations" array. add_motion -> CSequence::append_animation (line 301777) creates one new AnimSequenceNode per MotionData::anims[i] entry and DLListBase::InsertAfters it at the tail. this->first_cyclic marks where the cyclic (looping) portion of the list begins; remove_cyclic_anims trims everything from first_cyclic onward when a new transition starts (so clear_physics + remove_cyclic_anims together mean: "keep any link anim that's mid-playback [it's before first_cyclic], but throw away the old loop"). curr_anim points at the node currently being played; CSequence::update advances frame_number within curr_anim and, in apricot(), walks curr_anim forward through the list once frames are exhausted for a node, discarding fully-consumed non-cyclic nodes from the front of the list up to first_cyclic.

Sub-question: is there blending? No cross-fade/blend in the graphics sense. It's sequential: link_anim -> cyclic_anim, back-to-back play, and the crossover is a hard node-swap at frame boundary (see Q4). "Blending" in this codebase means the CSequence.velocity/omega accumulators (float vectors) are algebraically combined (combine_motion/subtract_motion/add_motion add or subtract scaled contributions) — that's a physics-level blend of velocity, not a skeletal pose blend.

Sub-question: is there an immediate speed change? Only in the same-substate fast path (change_cycle_speed+subtract_motion+combine_motion all happen synchronously inside GetObjectSequence, i.e., on the SAME frame the wire message is processed — no interpolation of speed itself). For walk<->run (different substate), the VISIBLE speed change is gated behind the link anim's playback duration — velocity is whatever CSequence.velocity currently holds (the link anim's own authored velocity/omega, added via add_motion), and only once the cyclic node becomes current does the full run/walk cyclic velocity apply.

same_sign (line 298253, addr 0x00522260) — verbatim

int same_sign(float a, float b) {
    // true (1) if a and b are both >=0 or both <0 (treats 0 as non-negative);
    // this is the "is direction unchanged" test used to gate the same-cycle
    // fast path and to decide whether get_link needs a sign-aware lookup.
    return !(a<0) == !(b<0);   // (pseudocode paraphrase of the FCMP branches)
}

change_cycle_speed (line 298276, addr 0x00522290) — verbatim constant

void change_cycle_speed(CSequence* seq, MotionData* cyc, float oldSpeed, float newSpeed) {
    if (fabs(oldSpeed) >= 0.000199999995f)             // EPSILON = ~0.0002
        CSequence::multiply_cyclic_animation_fr(seq, newSpeed / oldSpeed);   // rescale framerate
    else if (fabs(newSpeed) >= 0.000199999995f)
        CSequence::multiply_cyclic_animation_fr(seq, 0.0f);                  // freeze (old speed ~0)
    // else: both ~0, no-op
}

This is literally "new playback rate multiplier = newSpeed / oldSpeed" applied to every node from sequence->first_cyclic onward (AnimSequenceNode::multiply_framerate, line 302425) — so a walk<->walk speed change (same substate) scales animation playback speed proportionally to the commanded speed ratio, and ALSO swaps low_frame/high_frame if the new multiplier is negative (playing the cycle backward).


Q3 — PENDING_MOTIONS / MOTION_DONE lifecycle

There are TWO distinct pending-queues, easy to conflate:

  1. CMotionInterp::pending_motions (singly-linked LListData, fields: [next, context_id, motion, jumpAllowedFlag]). Owner: CMotionInterp. Appended by CMotionInterp::add_to_queue (line 305032, addr 0x00527b80) — called from DoInterpretedMotion (line 305607), StopInterpretedMotion (line 305657), apply_interpreted_movement's idle-stop path (line 305775), and StopCompletely (line 305227). Popped ONLY by CMotionInterp::MotionDone (line 305238, addr 0x00527ec0):

    void MotionDone(CMotionInterp* this, int arg2) {
        if (physics_obj == null) return
        head = pending_motions.head_
        if (head != null) {
            if (head->motion & 0x10000000) {       // this queued motion carried
                                                     // a server "action" (jump
                                                     // charge etc.)
                CPhysicsObj::unstick_from_object(physics_obj)
                InterpretedMotionState::RemoveAction(&interpreted_state)
                RawMotionState::RemoveAction(&raw_state)
            }
            pop head off pending_motions (delete node)
        }
    }
    

    CMotionInterp::motions_pending() (line 305322) == pending_motions.head_ != null. context_id/action_stamp on this queue is used for jump-charge and other server-acknowledged "actions", NOT for ordinary walk/run — ordinary DoInterpretedMotion calls still push a node here (so motions_pending() reflects "any interpreted motion is mid-flight"), but nothing about walk<->run reads the context_id/action semantics.

  2. MotionTableManager::pending_animations (doubly-linked DLListBase, fields: [motion_id, tickCount]), plus MotionTableManager::animation_counter (running decrement counter). Owner: MotionTableManager (one per CPartArray, i.e. per rendered mesh/skeleton — this is the ANIMATION-frame -level completion tracker, distinct from #1's motion-command-level tracker). Appended by MotionTableManager::add_to_queue (line 290854, addr 0x0051bfe0) every time GetObjectSequence succeeds, storing the outTicks value it returned (how many more discrete animation "steps"/frames worth of non-cyclic content remain before this motion is fully consumed). Also immediately calls remove_redundant_links (line 290771) to prune already-queued-but-superseded link-transition entries (see Q2 note on walk<->run spam).

    Consumption / popping — TWO drivers:

    • Per-tick poll: MotionTableManager::CheckForCompletedMotions (line 290645, addr 0x0051be00), called every physics tick via CPartArray::HandleMovement -> MotionTableManager::UseTime (alias for CheckForCompletedMotions, line 290845) from CPhysicsObj::UpdateObjectInternal (line 283748). Walks pending_animations from the head while tickCount == 0, firing CPhysicsObj::MotionDone(physics_obj, motion_id, /*arg3*/1) for each and removing action-heads (MotionState::remove_action_head) if the 0x10000000 bit is set.
    • Anim-hook driven: CPhysicsObj::Hook_AnimDone (line 277845, addr 0x0050fda0) — registered as a CAnimHook fired by CSequence::execute_hooks (line 300780) when a specific animation FRAME carries a hook whose direction_ matches playback direction. Calls CPartArray::AnimationDone(1) -> MotionTableManager::AnimationDone(1) (line 290558, addr 0x0051bce0), which increments animation_counter and pops every pending_animations entry whose tickCount <= animation_counter (decrementing the counter by each popped entry's tickCount, i.e. a running-total consumption model, not a strict per-frame countdown).
    • Synchronous, post-dispatch: CMotionInterp::PerformMovement (line 306221, addr 0x00528e80) — the outer entry used by the LOCAL player's raw input path (MovementManager::PerformMovement cases 0-4) — calls CPhysicsObj::CheckForCompletedMotions immediately after every DoMotion/DoInterpretedMotion/StopMotion/StopInterpretedMotion/ StopCompletely dispatch (line 306234/241/248/255/262), so a zero-duration motion completes in the SAME frame it was issued rather than waiting for the next tick. This path is NOT used by the wire/remote entry (CMotionInterp::apply_interpreted_movement calls DoInterpretedMotion/StopInterpretedMotion directly without a following CheckForCompletedMotions — the remote entity therefore only gets its completions serviced by the per-tick poll and the anim-hook path, not the synchronous one).

    CPhysicsObj::MotionDone(physics_obj, motion_id, arg3) (line 277856, addr 0x0050fdb0) -> MovementManager::MotionDone (line 300396) -> CMotionInterp::MotionDone (line 305238, described in #1 above). This is the bridge between the two queues: a MotionTableManager-level animation-frame completion cascades UP into popping the CMotionInterp-level command queue.

Callback / state change on completion: popping pending_motions only (a) optionally clears the "stuck to object" state + removes a pending server action if the popped node had the 0x10000000 "carries an action" bit, and (b) frees the node. It does NOT itself touch velocity, substate, or the CSequence node list — those were already mutated synchronously back when GetObjectSequence ran (at command-ISSUE time, not command-COMPLETE time). The animation-frame-level completion (AnimationDone/CheckForCompletedMotions) is what actually matters for gameplay feel: it's what lets a queued non-cyclic link anim naturally hand off to the next queued node (see Q4) and what lets a "jump" or other single-shot server action be acknowledged as finished.


Per CSequence::append_animation (line 301777, addr 0x00525510):

void append_animation(CSequence* this, AnimData* animData) {
    node = new AnimSequenceNode(animData)
    if (!node->has_anim()) { delete node immediately; return }   // degenerate/empty motion, skip
    DLListBase::InsertAfter(&anim_list, node, anim_list.tail_)   // always appended at TAIL
    this->first_cyclic = node                                    // *** every appended node
                                                                   //     becomes the new
                                                                   //     first_cyclic marker
                                                                   //     until superseded ***
    if (curr_anim == null) {          // sequence was idle/empty
        curr_anim = anim_list.head_
        frame_number = curr_anim->get_starting_frame()
    }
    // if curr_anim was already non-null (something mid-playback), it is
    // left untouched — the newly appended node just waits at the tail.
}

So appending never resets frame_number for whatever's currently playing. The frame index of the CURRENTLY playing node (the link anim, or the old cycle if it's still curr_anim) is untouched.

CSequence::update (line 302402, addr 0x00525b80):

void update(CSequence* this, double dt, Frame* outDelta) {
    if (anim_list.head_ != null) {
        CSequence::update_internal(this, dt, &curr_anim, &frame_number, outDelta)
        CSequence::apricot(this)     // list-trim housekeeping (below)
    } else if (outDelta != null) {
        CSequence::apply_physics(this, outDelta, dt, dt)   // PURE velocity integration,
                                                              // no animation nodes at all
    }
}

update_internal (line 301839, addr 0x005255d0) is heavily x87-obfuscated in this decompile (unresolvable float compares/branches show as raw /* unimplemented */ FPU op comments) — the BN decompiler could not fully recover its control flow. What IS recoverable: it advances frame_number within curr_anim by dt * anim->framerate-derived amount, and once a node's frames are exhausted it walks curr_anim to AnimSequenceNode::GetNext(...) (confirmed indirectly via apricot's cleanup logic below and via CSequence::execute_hooks/multiply_cyclic_animation_fr operating on "first_cyclic onward" — i.e., cyclic nodes loop in place by wrapping frame_number, non-cyclic/link nodes advance curr_anim to the next list node when frames are exhausted).

CSequence::apricot (line 300978, addr 0x00524b40) — the list-trim called every update():

void apricot(CSequence* this) {
    i = (anim_list.head_ != null) ? adjustedHead : null
    if (i != curr_anim) {
        while (i != first_cyclic) {
            // unlink node i from anim_list (both directions), delete it,
            // then advance i to the new head
            ... unlink + delete ...
            i = new head
            if (i == curr_anim) break
        }
    }
}

i.e., once curr_anim has moved past the head of the list (a node finished playing), apricot deletes every now-stale node from anim_list.head_ up to (but not including) first_cyclic. This is standard "consume finished one-shot link anims off the front of the queue" behavior.

Direct answer: the frame index does NOT carry over between the OLD cycle and the NEW cycle — they are different AnimSequenceNode objects wrapping different CAnimation data with independently-tracked start frames (AnimSequenceNode::get_starting_frame()). What DOES carry over/continue smoothly is:

  • the link animation plays out fully first (its own authored frame range, from get_starting_frame() to its end), because it was appended to the tail and curr_anim only advances once the current node's frames are exhausted (via update_internal's internal advance, not apricot, which is just cleanup).
  • once the link anim's frames are exhausted, playback naturally proceeds to the next node in anim_list (the newly appended cyclic walk/run node), which starts fresh at ITS get_starting_frame().
  • So the transition literally IS the link animation: walk -> run uses an authored transition clip in between; there is no cross-fade of the walk cycle's frame position into the run cycle's frame position. The retail art pipeline authors these link/transition clips specifically so this hard swap looks continuous.
  • If get_link found NO authored link for this style/substate pair (the linkAnim == null branch in GetObjectSequence), the code instead hops through the style's "default" (idle/ready) substate as an intermediate — two link anims chained — rather than doing a raw cut.
  • change_cycle_speed's multiply_cyclic_animation_fr (called ONLY on the same-substate fast path) operates on this->first_cyclic onward, i.e., it re-times whatever is the CURRENT cyclic node in place — it does not touch frame_number's absolute position within that node, only its rate of advance, so a walk-speed-change (not walk<->run) preserves the current frame's phase, just plays faster/slower/backward from there.

Q5 — POSITION DRIVE between inbound packets

Confirmed by tracing CPhysicsObj::update_object -> UpdateObjectInternal -> UpdatePositionInternal -> CPartArray::Update -> CSequence::update:

CPhysicsObj::update_object(CPhysicsObj* this)      line 283950, addr 0x00515d10
{
    ... skip if parented/no-cell/frozen ...
    dt = Timer::cur_time - this->update_time
    if dt < 0.000199999995f: return                 // EPSILON, same constant as change_cycle_speed
    if dt < 2.0:
        UpdatePositionInternal-chain for the whole dt in one call
    else:
        // clamp/step: chunk into <=1.0s steps while remaining dt >= 2.0,
        // then one final UpdateObjectInternal(remainder) call — prevents a
        // huge single-frame teleport after e.g. a stall/loading hitch
        while (remaining >= 2.0) { UpdateObjectInternal(this, 1.0); remaining -= 1.0 }
        UpdateObjectInternal(this, remaining)
}

CPhysicsObj::UpdateObjectInternal(CPhysicsObj* this, float dt)    line 283611, addr 0x005156b0
{
    ... early-outs for ethereal/off-world states, still runs particle/script update ...
    if (this->cell != 0) {
        var deltaFrame = {identity}
        UpdatePositionInternal(this, dt, &deltaFrame)              // <-- computes the candidate move
        if (has spheres / real collision geometry) {
            if (deltaFrame == zero-delta) {
                set_frame(this, &deltaFrame); cached_velocity = 0
            } else {
                heading update (velocity-derived or state-flag-derived)
                CTransition* result = CPhysicsObj::transition(this, &m_position, &deltaFrame, 0)  // <-- FULL
                                                                                                     //     COLLISION
                                                                                                     //     SWEEP,
                                                                                                     //     same
                                                                                                     //     machinery
                                                                                                     //     as local
                                                                                                     //     player
                                                                                                     //     movement
                if (result == null) {
                    set_frame(this, &deltaFrame)      // blocked entirely -> stays put, but frame still applied??
                                                        // (this branch means find_valid_position failed to
                                                        //  produce a transition object; effectively a no-collision
                                                        //  passthrough for objects without real spheres)
                    cached_velocity = 0
                } else {
                    cached_velocity = (result->sphere_path.curr_pos - m_position) / dt   // ACTUAL POST-COLLISION
                                                                                            // velocity, NOT the
                                                                                            // raw commanded one
                    SetPositionInternal(this, result)
                }
            }
        } else {
            // no collision spheres on this part array: apply frame directly, no sweep
            set_frame(this, &deltaFrame); cached_velocity = 0
        }
        DetectionManager / TargetManager / MovementManager::UseTime / CPartArray::HandleMovement
                                                                        (== MotionTableManager::UseTime
                                                                         == CheckForCompletedMotions) /
        PositionManager::UseTime
    }
}

CPhysicsObj::UpdatePositionInternal(CPhysicsObj* this, float dt, Frame* outDelta)   line 280817, addr 0x00512c30
{
    if (!ethereal-ish state bit): CPartArray::Update(part_array, dt, outDelta)   // <-- FILLS outDelta
                                                                                    //     via CSequence::update
    if (position_manager != null): PositionManager::adjust_offset(position_manager, outDelta, dt)
                                                                    // (server position-correction blend, see Q6)
    Frame::combine(outDelta, &this->m_position.frame, outDelta)     // outDelta = currentFrame (+) outDelta
    if (!ethereal-ish): CPhysicsObj::UpdatePhysicsInternal(this, dt, outDelta)   // gravity/step physics on top
    CPhysicsObj::process_hooks(this)     // <-- fires queued CAnimHooks (incl. AnimDone) EVERY TICK, post-position
}

CPartArray::Update(CPartArray* this, float dt, Frame* outDelta)    line 285883, addr 0x00517db0
{
    CSequence::update(&this->sequence, dt, outDelta)   // exactly the branch described in Q4:
                                                          // animation-node-consumption path OR
                                                          // pure apply_physics(velocity*dt) fallback
}

Direct answer: BOTH, and they are the SAME code path, not two competing sources. CSequence::update chooses between: (a) animation-node consumption (update_internal) when anim_list is non-empty — this advances frames AND, per-node, the per-frame position delta baked into the AnimFrame data (get_pos_frame/get_part_frame) contributes to the produced outDelta Frame (the x87-obscured part of update_internal, but its role is confirmed by AnimSequenceNode::get_pos_frame / get_part_frame existing specifically to fetch per-frame authored pose+position data), and (b) apply_physics (pure outDelta.origin += dt * this->velocity; outDelta.rotate(dt * this->omega)) when anim_list is EMPTY (i.e. a pure-interpreted-velocity idle/moving state with no queued transition animations left) — this is the steady-state "walking/running in a straight line between server packets" case for a LOOPING cyclic anim once its own list bookkeeping considers it "done producing new nodes" — but note has_anims() / anim_list.head_ != null is true whenever there's ANY node (including the still-looping cyclic one), so in practice, for a normal walk/run cycle, path (a) is what's active essentially always; path (b) is the true-idle / "no motion data at all, just raw velocity" fallback (e.g. after StopCompletely clears everything, or for objects that were never given a motion table). Either way, the output Frame delta is what feeds Frame::combine against the CURRENT position, and the combined candidate then goes through the FULL CPhysicsObj::transition collision sweep — remote entities are collision-checked every tick exactly like the local player, they are not simply "teleported" along a straight line. cached_velocity (used for e.g. UI/physics queries, NOT for driving the next tick's move — the next tick re-derives everything from CSequence state) is the ACTUAL post-collision displacement/dt, which can differ from the commanded interpreted velocity if a wall was hit.


Q6 — CORRECTION: reconciling inbound position updates

Two independent correction paths were located; both are called from UpdatePositionInternal/its callers, gated by whether the wire message carried a full Position update or just a motion-command update:

  1. PositionManager::adjust_offset (called every tick from UpdatePositionInternal, line 280857) — blends a stored "we're behind where we should be" offset into the per-tick delta over time, i.e. a position-manager-owned soft-correction/interpolation smoothing layer (PositionManager::UnStick/StopInterpolating/IsInterpolating/ IsFullyConstrained/GetStickyObjectID are its other exposed operations — all wrapped 1:1 through CPhysicsObj::unstick_from_object, StopInterpolating, IsInterpolating, IsFullyConstrained, get_sticky_object_id). The named-retail decompile does not expose PositionManager::adjust_offset's internal body in this file (its class implementation lives outside the traced call chain reached in this pass); what's confirmed is its CALL SITE and its INPUT/OUTPUT contract: it mutates the same Frame* outDelta that CPartArray::Update/CSequence::update just wrote, i.e. it's a correction applied ON TOP OF the animation/velocity-driven delta, before that delta is combined with current position and swept for collision. This is the retail equivalent of "dead-reckoning error absorbed gradually into the next frame's move" rather than a hard position snap.
  2. Full snap path: when 0xf74c/0xf619 carries not just a motion command but also a fresh authoritative Position (the position+movement combo case, or MoveToObject/MoveToPosition in unpack_movement's cases 6/7), the code calls CPhysicsObj::SetPositionInternal (line 283892, addr 0x00515bd0) via the MoveToManager/CPhysicsObj::MoveToObject/ SetScatterPositionInternal machinery — this is a direct authoritative Position set (through AdjustPosition + CheckPositionInternal + handle_all_collisions), i.e. a hard reposition/snap when the server sends a full position rather than only a motion-state delta. unpack_movement case 0 (the plain InterpretedMotionState, used for ordinary walk<->run) does NOT carry a Position at all — it only ever updates the motion command/speed and lets local dead-reckoning (CSequence-driven update + collision sweep, per Q5) carry the position forward until the next authoritative position or motion packet arrives. There is no visible "snap-if-error-exceeds-threshold" constant found in the traced functions in this pass — the correction is structurally continuous (adjust_offset blended every tick) rather than threshold-triggered, based on what's directly observable in this file.

Q7 — STOP: motion -> ready/stand

Stopping is not special-cased outside the normal GetObjectSequence machinery — it is routed through the exact same link-transition logic as any other substate change, targeting the style's registered idle/rest substate.

Entry points, both eventually reaching CMotionTable::StopSequenceMotion (line 298954, addr 0x00522fc0):

CMotionInterp::StopInterpretedMotion(this, motion, params)   line 305635, addr 0x00528470
  -> if contact_allows_move fails OR standing_longjump-with-jump-motion:
       just clears bookkeeping (InterpretedMotionState::RemoveMotion) and returns 0 — no
       physical stop is even attempted (e.g. can't "stop turning" mid-air the same way)
  -> else:
       CPhysicsObj::StopInterpretedMotion(physics_obj, motion, params)
         -> CPartArray::StopInterpretedMotion -> MotionTableManager::PerformMovement(type=StopCommand)
              -> CMotionTable::StopObjectMotion(table, motion, speed, state, seq, outTicks)
                   -> CMotionTable::StopSequenceMotion(table, motion, speed, state, seq, outTicks)
       if success: CMotionInterp::add_to_queue(this, ctx, READY_STANCE/*0x41000003*/, result)
       InterpretedMotionState::RemoveMotion(&interpreted_state, motion)   // clears forward_command
                                                                            // back to 0x41000003 READY

CMotionTable::StopSequenceMotion (line 298954, addr 0x00522fc0):

int32 StopSequenceMotion(table, motion, speed, state, seq, outTicks) {
    *outTicks = 0
    if ((motion & 0x40000000) != 0 && motion == state->substate) {
        // stopping the MAIN cycle (forward walk/run, not a modifier like
        // sidestep): look up the style's default (idle/ready) substate and
        // re-enter GetObjectSequence targeting IT — i.e. "stop" == "transition
        // to idle", full link-anim machinery applies (Q2/Q4)
        defaultSubstate = style_defaults[state->style]
        return CMotionTable::GetObjectSequence(table, defaultSubstate, state, seq, 1.0f, outTicks, /*force*/1)
    }
    if ((motion & 0x20000000) != 0) {
        // stopping a MODIFIER motion (e.g. sidestep, turn — layered on top of
        // the base cycle rather than replacing it): find the modifier's
        // MotionData and directly SUBTRACT its velocity/omega contribution
        for (m in state->modifier_head-list) {
            if (m.motion == motion) {
                modData = modifiers.lookup((style<<16)|motion) ?? modifiers.lookup(motion)
                if (modData != null) {
                    subtract_motion(seq, modData, m.speed_mod)   // <-- direct velocity/omega
                                                                    //     subtraction, NO link anim,
                                                                    //     NO node changes — this IS
                                                                    //     how e.g. releasing sidestep
                                                                    //     while still running removes
                                                                    //     just the sideways component
                    MotionState::remove_modifier(state, m, prev)
                    return 1
                }
                break
            }
        }
    }
    return 0
}

Velocity zeroing: happens in TWO places depending on stop type:

  • Main-cycle stop (walk/run -> ready): via GetObjectSequence's link-transition branch, which unconditionally calls CSequence::clear_physics(sequence) BEFORE appending the new link+cycle chain — clear_physics (line 301194, addr 0x00524d50) zeroes sequence->velocity and sequence->omega outright, then the new link anim's OWN authored velocity/omega (if any) is added back via add_motion. So there IS a hard zero, immediately followed by re-population from the transition-to-idle clip's own baked velocity/omega (typically ~0 for a stand/ready clip, hence "stop").
  • Modifier stop (sidestep/turn release): subtract_motion directly removes exactly that modifier's contribution (scaled by its speed_mod) from the still-nonzero base-cycle velocity — no full zero, no clear_physics call, because the base cycle (e.g. still running) keeps its own velocity intact.

Stop/link animation: YES — the idle-entry is itself an authored get_link transition clip from the current substate to the style's default substate, exactly like any other substate-to-substate transition (Q2/Q4). There is no "instant freeze frame"; retail plays a deceleration/stop clip.

Residual-sliding prevention: because clear_physics zeroes sequence->velocity/omega at the moment the stop-transition is initiated (not merely when the stop ANIMATION finishes), the apply_physics/animation per-frame delta stops contributing translation from THAT frame onward except whatever the stop-link-clip's own authored motion data supplies via add_motion(seq, linkAnim, ...) immediately after the clear. So there's no "physics keeps sliding while the stop anim plays" bug window — the only motion during the stop-link clip is whatever the clip's OWN keyframed velocity says (typically small/decelerating by design), and once the link clip's frames are exhausted and playback reaches the (typically near-static) idle cyclic node, velocity is whatever that idle cycle's own add_motion(..., cycleData, speed) contributed (near zero for a proper "Ready"/idle motion).

Additionally: CPhysicsObj::RemoveLinkAnimations (-> CPartArray::HandleEnterWorld which is really "flush the motion table manager's queued link anims", called from multiple guard points: whenever physics_obj->cell == 0 inside both DoInterpretedMotion and StopInterpretedMotion's tail, from HitGround, LeaveGround, and from move_to_interpreted_state's caller context indirectly) provides a hard-reset safety valve: if the object leaves the world/cell mid-transition, any queued link-transition chain is discarded outright rather than left dangling.


Verbatim float constants collected in this pass

Constant Where Meaning
0.000199999995f (~0.0002) change_cycle_speed (298276), CPhysicsObj::update_object dt-epsilon (283950 area), CPhysicsObj::SetTranslucency (279489) Generic "is this float effectively zero" epsilon used repeatedly across the physics/motion code — NOT walk/run-specific but the exact epsilon guarding the same-cycle speed-rescale divide-by-oldSpeed.
2.0 (dt seconds) CPhysicsObj::update_object (~284009) Large-dt chunking threshold: any single update_object gap >= 2.0s is stepped in 1.0s UpdateObjectInternal slices to avoid one huge teleport-y integration step.
1.0 (dt seconds) same function Per-slice step size used while chunking large dt.
1.25f CMotionInterp::get_state_velocity (305160) Sidestep speed multiplier when computing "logical state velocity" (sidestep_speed * 1.25f) — used for e.g. UI/AI queries, not the actual CSequence velocity.
1.5f CMotionInterp::apply_run_to_command (305062), motion 0x6500000d (TURN) case Speed multiplier applied to turn commands.
3f / -1f*3f CMotionInterp::apply_run_to_command, motion 0x6500000f (SIDESTEP) case Sidestep speed is clamped/scaled to exactly +-3.0 depending on sign of the run-rate-scaled input (with sign preserved via the x87_r7 < 0 branch).
3.11999989f (~3.12) CMotionInterp::get_state_velocity (305176) Walk-forward (0x45000005) logical-velocity multiplier.
4f CMotionInterp::get_state_velocity (305180) Run-forward (0x44000007) logical-velocity multiplier.
96f CPhysicsObj::update_object (283974, player_distance gate) Distance (world units, ~yards? — needs unit confirmation) beyond which a different set_active path is taken for a non-player-object relative to the player.
0.100000001f (0.1) CPhysicsObj::set_elasticity (277817) Elasticity clamp floor — unrelated to motion but shares the file region.

Function/line index (quick lookup for a synthesis pass)

Symbol Line Addr Role
ACSmartBox::DispatchSmartBoxEvent 357117 0x005595d0 Wire opcode switch (0xf619/0xf74c entry)
CPhysics::SetObjectMovement (stdcall) 271370 0x00509690 Sequence-number staleness gate, dispatch
CPhysicsObj::unpack_movement 280179 0x00512040 Lazily creates MovementManager, forwards
MovementManager::unpack_movement 300563 0x00524440 Deserializes wire struct, 10-way type switch
MovementManager::move_to_interpreted_state 300259 0x00524170 Lazy-create CMotionInterp, forward
CMotionInterp::move_to_interpreted_state 305936 0x005289c0 copy_movement_from + apply_current_movement + replay actions
InterpretedMotionState::copy_movement_from 293301 0x0051e750 Flat field overwrite (fwd/side/turn cmd+speed, style)
CMotionInterp::apply_current_movement 305838 0x00528870 Routes to raw (local) vs interpreted (remote) path
CMotionInterp::apply_interpreted_movement 305713 0x00528600 Issues DoInterpretedMotion per active command slot
CMotionInterp::DoInterpretedMotion 305575 0x00528360 contact_allows_move gate, dispatch to CPhysicsObj, queue
CPhysicsObj::DoInterpretedMotion 276348 0x0050ea70 Thin forward to CPartArray
CPartArray::DoInterpretedMotion 286772 0x00518750 Packs MovementStruct{type=2}, calls MotionTableManager
MotionTableManager::PerformMovement 290906 0x0051c0b0 type switch: DoObjectMotion / StopObjectMotion / StopObjectCompletely
CMotionTable::DoObjectMotion 300045 0x00523e90 -> GetObjectSequence(force=0)
CMotionTable::GetObjectSequence 298636 0x00522860 THE cycle-swap/append/fast-path decision
same_sign 298253 0x00522260 Direction-unchanged test
change_cycle_speed 298276 0x00522290 Same-cycle playback-rate rescale
CMotionTable::get_link 298552 0x00522710 Authored transition-anim lookup
add_motion / combine_motion / subtract_motion 298437 / 298472 / 298492 0x005224b0 / 0x00522580 / 0x00522600 Append CSequence nodes + scale velocity/omega in/out
CSequence::append_animation 301777 0x00525510 Node creation, tail-insert, first_cyclic bump
CSequence::clear_physics 301194 (def not read in full but referenced) 0x00524d50 Zero velocity/omega
CSequence::remove_cyclic_anims referenced 298701 etc 0x00524e40 Drop old cyclic tail before new transition
CSequence::update 302402 0x00525b80 update_internal (has anims) OR apply_physics (no anims)
CSequence::update_internal 301839 0x005255d0 Frame-advance (x87-obfuscated, not fully recoverable)
CSequence::apply_physics 300955 0x00524ab0 outDelta.origin += dtvelocity; rotate(dtomega)
CSequence::apricot 300978 0x00524b40 Trim consumed nodes from head up to first_cyclic
CPartArray::Update 285883 0x00517db0 == CSequence::update
CPhysicsObj::UpdatePositionInternal 280817 0x00512c30 CPartArray::Update -> PositionManager::adjust_offset -> Frame::combine -> UpdatePhysicsInternal -> process_hooks
CPhysicsObj::UpdateObjectInternal 283611 0x005156b0 UpdatePositionInternal -> collision transition -> cached_velocity, per-tick UseTime calls
CPhysicsObj::update_object 283950 0x00515d10 Outer per-object driver, dt clamp/chunking
CPhysicsObj::transition 280904 0x00512dc0 Builds CTransition, sphere sweep, find_valid_position
MotionTableManager::add_to_queue 290854 0x0051bfe0 Append to pending_animations, prune redundant links
MotionTableManager::remove_redundant_links 290771 0x0051bf20 Collapse superseded queued link transitions
MotionTableManager::CheckForCompletedMotions 290645 0x0051be00 Per-tick poll: pop tickCount==0 entries, fire MotionDone
MotionTableManager::AnimationDone 290558 0x0051bce0 Anim-hook-driven pop via animation_counter
CPhysicsObj::Hook_AnimDone 277845 0x0050fda0 CAnimHook callback -> CPartArray::AnimationDone(1)
CPhysicsObj::MotionDone 277856 0x0050fdb0 Bridges MotionTableManager completion -> CMotionInterp queue
MovementManager::MotionDone 300396 0x005242d0 Forward
CMotionInterp::MotionDone 305238 0x00527ec0 Pops pending_motions head, clears stick/actions if flagged
CMotionInterp::add_to_queue 305032 0x00527b80 Appends to CMotionInterp::pending_motions
CMotionInterp::motions_pending 305322 0x00527fe0 pending_motions.head_ != null
CMotionInterp::StopInterpretedMotion 305635 0x00528470 Entry for stopping a command
CMotionTable::StopObjectMotion 300053 0x00523ec0 -> StopSequenceMotion
CMotionTable::StopSequenceMotion 298954 0x00522fc0 Main-cycle-stop (re-enter GetObjectSequence w/ default substate) vs modifier-stop (subtract_motion)
CMotionTable::StopObjectCompletely 300062 0x00523ed0 Iterates all modifiers + substate, stops each
InterpretedMotionState::ApplyMotion 293531 0x0051ea40 Bookkeeping-only overwrite of forward/sidestep/turn fields
InterpretedMotionState::RemoveMotion 293315 0x0051e790 Clears turn/sidestep/forward command back to defaults
CPhysicsObj::RemoveLinkAnimations 277911 0x0050fe20 -> CPartArray::HandleEnterWorld: flush queued link anims
CMotionInterp::contact_allows_move 305471 0x00528240 Gate: only creatures on solid ground can freely swap most motions
CMotionInterp::PerformMovement 306221 0x00528e80 LOCAL-input outer dispatcher; calls CheckForCompletedMotions synchronously (NOT used by remote/wire path)

Notes on scope / what was NOT fully resolved

  • CSequence::update_internal's exact per-frame arithmetic (how frame_number advances, exact interpolation between low_frame/high_frame, and the precise mechanism by which a per-frame authored position delta from AnimFrame/get_pos_frame gets folded into the output Frame*) is x87-obfuscated in this Binary Ninja pseudo-C dump — individual FPU compare/branch sequences show as /* unimplemented {fcomp ...} */ rather than resolved C. This matches the documented project-wide limitation (see memory/feedback_bn_decomp_field_names.md and the CLAUDE.md cdb-toolchain section) that some floating-point-heavy retail functions don't fully decompile via Binary Ninja and may need a cdb live-trace or manual disassembly pass to pin exact behavior. What IS certain from the surrounding code (append_animation/get_pos_frame/get_part_frame/apricot) is the STRUCTURE: node-list advance + per-node authored frame data feeding the output Frame.
  • PositionManager::adjust_offset's body (the Q6 soft-correction blend) was not located inside this pseudo-C excerpt in this pass — only its call site and sibling API surface (UnStick, StopInterpolating, IsInterpolating, IsFullyConstrained, GetStickyObjectID) were confirmed. A follow-up grep for PositionManager:: method bodies (likely a different source file / address range not covered by the anchors given) would be needed to get its exact blend formula and any snap-threshold constant.
  • No explicit "snap if error > threshold" constant was found for position correction in the portions traced; the retail design as observed here is a continuous per-tick blend (adjust_offset) plus occasional authoritative hard SetPositionInternal when the wire message actually carries a Position (MoveToObject/MoveToPosition/PositionAndMovement paths), not a distance-threshold-triggered snap layered on top of ordinary motion-command packets.