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

969 lines
57 KiB
Markdown

# 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::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.