# 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 = CPhysicsObj::MoveToObject(physics_obj, target_guid, ¶ms) 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, ¶ms) } } 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::InsertAfter`s 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.** --- ## Q4 — CYCLE SWAP: frame index carryover vs restart vs link 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 += dt*velocity; rotate(dt*omega) | | `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.