/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>
57 KiB
Retail decomp map: INBOUND remote-entity motion pipeline
Source: docs/research/named-retail/acclient_2013_pseudo_c.txt (66 MB, Sept 2013
EoR build, PDB-named pseudo-C). All line numbers below are LINE NUMBERS in that
file (not addresses), from a Sept-2026 checkout at
C:/Users/erikn/source/repos/acdream/.claude/worktrees/vigorous-joliot-f0c3ad.
Addresses (0x0051xxxx etc.) are also given per function for cross-reference with
symbols.json / Ghidra.
Q1 — INBOUND ENTRY: wire message -> motion interpreter
Full call chain, outermost (network) to innermost (motion-table state machine):
ACSmartBox::DispatchSmartBoxEvent(NetBlob*) line 357117 (0x005595d0)
switch (opcode) {
case 0xf619: // "Movement" — the live/current movement update
SmartBox::UnpackPositionEvent(...) line 357142
if result == NETBLOB_PROCESSED_OK:
CPhysicsObj* obj = CObjectMaint::GetObjectA(pObjMaint, guid)
CPhysics::SetObjectMovement(physics, obj, buf, bufSize) line 357154 (call), def @271370 (0x00509690)
if nonzero: cmdinterp->LoseControlToServer()
case 0xf74c: // position+movement combo, has an extra u16 seq check first
is_newer(obj->update_times[8], seq) gate line 357224
CPhysics::SetObjectMovement(physics, obj, buf, bufSize) line 357232
}
CPhysics::SetObjectMovement (2-arg overload used for 0xf619/0xf74c dispatch,
__stdcall, line 271370 / addr 0x00509690):
int32 SetObjectMovement(CPhysics* this, CPhysicsObj* obj, buf, bufLen,
u16 seqA, u16 seqB, bool isAutonomous)
{
isPlayer = obj->weenie_obj && obj->weenie_obj->IsThePlayer();
// 16-bit wraparound-aware "is this sequence number newer" compare,
// done TWICE against two independent counters obj->update_times[1]
// and obj->update_times[5]:
diff = |seqA - obj->update_times[1]| (mod 0x10000)
newer = (diff > 0x7fff) ? (seqA < old) : (old < seqA) // wraparound rule
if not newer: return 0 // STALE PACKET, DROPPED
obj->update_times[1] = seqA
diff2 = |obj->update_times[5] - seqB|
newer2 = (diff2 > 0x7fff) ? (old < seqB) : (seqB < old)
if not newer2: return 0 // STALE, DROPPED
obj->update_times[5] = seqB
if (!isAutonomous || !isPlayer) { // remote entity ALWAYS
// takes this branch;
// local player only
// takes it when NOT
// self-driving (server
// override / rubber-band)
obj->last_move_was_autonomous = isAutonomous
CPhysicsObj::unpack_movement(obj, &buf, bufLen) line 271423 (0x00509742)
if isPlayer: return 1 // signals caller to call LoseControlToServer
}
return 0
}
CPhysicsObj::unpack_movement (line 280179, addr 0x00512040):
void unpack_movement(CPhysicsObj* this, buf**, bufLen)
{
if (this->movement_manager == null)
this->movement_manager = MovementManager::Create(this, this->weenie_obj)
MovementManager::unpack_movement(this->movement_manager, buf, bufLen) line 280202
}
MovementManager::unpack_movement (line 300563, addr 0x00524440) — deserializes
the wire struct and dispatches to ONE of 10 sub-cases (command_ids[ecx_4] type
tag read from the first u16 in the buffer, case 0..9 via jump table @300707):
switch (type_tag) {
case 0: // InterpretedMotionState (RawMotionState command wrapper) -- THIS
// is the walk/run/turn/sidestep command path used for remote
// players AND monsters
InterpretedMotionState::UnPack(&ims, buf, bufLen) line 300606
// optional trailing u32 = "sticky" target object id
MovementManager::move_to_interpreted_state(this, &ims) line 300618 (0x0052457c)
if sticky_id != 0: CPhysicsObj::stick_to_object(...)
motion_interpreter->standing_longjump = (type_tag & 0x200)
return 1
case 6: // MoveToObject
Position::UnPackOrigin + MovementParameters::UnPackNet(MoveToObject)
motion_interpreter->my_run_rate = <wire float>
CPhysicsObj::MoveToObject(physics_obj, target_guid, ¶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_speedvalues) hits the fast path:change_cycle_speed+subtract_motion/combine_motion— sameAnimSequenceNodeobject, just re-timed and re-weighted. No new node, no restart. - Walk<->Run is a substate CHANGE (
0x45000005WalkForward vs0x44000007RunForward are different substate ids), so it does NOT hit the fast path. It goes through the link-transition path:get_linklooks 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 viaadd_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 theCSequence::update_internaladvance mechanism movescurr_animforward in theanim_listto the next node — the new cyclic walk/run anim — automatically (see Q4).
Sub-question (b): the CSequence node list. CSequence::anim_list is a
doubly-linked list (DLListBase of AnimSequenceNode), NOT a single
"QueuedAnimations" array. add_motion -> CSequence::append_animation (line
301777) creates one new AnimSequenceNode per MotionData::anims[i] entry and
DLListBase::InsertAfters it at the tail. this->first_cyclic marks where the
cyclic (looping) portion of the list begins; remove_cyclic_anims trims
everything from first_cyclic onward when a new transition starts (so
clear_physics + remove_cyclic_anims together mean: "keep any link anim
that's mid-playback [it's before first_cyclic], but throw away the old loop").
curr_anim points at the node currently being played; CSequence::update
advances frame_number within curr_anim and, in apricot(), walks
curr_anim forward through the list once frames are exhausted for a node,
discarding fully-consumed non-cyclic nodes from the front of the list up to
first_cyclic.
Sub-question: is there blending? No cross-fade/blend in the graphics sense.
It's sequential: link_anim -> cyclic_anim, back-to-back play, and the
crossover is a hard node-swap at frame boundary (see Q4). "Blending" in this
codebase means the CSequence.velocity/omega accumulators (float vectors)
are algebraically combined (combine_motion/subtract_motion/add_motion
add or subtract scaled contributions) — that's a physics-level blend of
velocity, not a skeletal pose blend.
Sub-question: is there an immediate speed change? Only in the same-substate
fast path (change_cycle_speed+subtract_motion+combine_motion all happen
synchronously inside GetObjectSequence, i.e., on the SAME frame the wire
message is processed — no interpolation of speed itself). For walk<->run
(different substate), the VISIBLE speed change is gated behind the link anim's
playback duration — velocity is whatever CSequence.velocity currently holds
(the link anim's own authored velocity/omega, added via add_motion), and only
once the cyclic node becomes current does the full run/walk cyclic velocity
apply.
same_sign (line 298253, addr 0x00522260) — verbatim
int same_sign(float a, float b) {
// true (1) if a and b are both >=0 or both <0 (treats 0 as non-negative);
// this is the "is direction unchanged" test used to gate the same-cycle
// fast path and to decide whether get_link needs a sign-aware lookup.
return !(a<0) == !(b<0); // (pseudocode paraphrase of the FCMP branches)
}
change_cycle_speed (line 298276, addr 0x00522290) — verbatim constant
void change_cycle_speed(CSequence* seq, MotionData* cyc, float oldSpeed, float newSpeed) {
if (fabs(oldSpeed) >= 0.000199999995f) // EPSILON = ~0.0002
CSequence::multiply_cyclic_animation_fr(seq, newSpeed / oldSpeed); // rescale framerate
else if (fabs(newSpeed) >= 0.000199999995f)
CSequence::multiply_cyclic_animation_fr(seq, 0.0f); // freeze (old speed ~0)
// else: both ~0, no-op
}
This is literally "new playback rate multiplier = newSpeed / oldSpeed" applied
to every node from sequence->first_cyclic onward
(AnimSequenceNode::multiply_framerate, line 302425) — so a walk<->walk speed
change (same substate) scales animation playback speed proportionally to the
commanded speed ratio, and ALSO swaps low_frame/high_frame if the new
multiplier is negative (playing the cycle backward).
Q3 — PENDING_MOTIONS / MOTION_DONE lifecycle
There are TWO distinct pending-queues, easy to conflate:
-
CMotionInterp::pending_motions(singly-linkedLListData, fields:[next, context_id, motion, jumpAllowedFlag]). Owner:CMotionInterp. Appended byCMotionInterp::add_to_queue(line 305032, addr 0x00527b80) — called fromDoInterpretedMotion(line 305607),StopInterpretedMotion(line 305657),apply_interpreted_movement's idle-stop path (line 305775), andStopCompletely(line 305227). Popped ONLY byCMotionInterp::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_stampon this queue is used for jump-charge and other server-acknowledged "actions", NOT for ordinary walk/run — ordinaryDoInterpretedMotioncalls still push a node here (somotions_pending()reflects "any interpreted motion is mid-flight"), but nothing about walk<->run reads thecontext_id/action semantics. -
MotionTableManager::pending_animations(doubly-linkedDLListBase, fields:[motion_id, tickCount]), plusMotionTableManager::animation_counter(running decrement counter). Owner:MotionTableManager(one perCPartArray, i.e. per rendered mesh/skeleton — this is the ANIMATION-frame -level completion tracker, distinct from #1's motion-command-level tracker). Appended byMotionTableManager::add_to_queue(line 290854, addr 0x0051bfe0) every timeGetObjectSequencesucceeds, storing theoutTicksvalue it returned (how many more discrete animation "steps"/frames worth of non-cyclic content remain before this motion is fully consumed). Also immediately callsremove_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 viaCPartArray::HandleMovement->MotionTableManager::UseTime(alias forCheckForCompletedMotions, line 290845) fromCPhysicsObj::UpdateObjectInternal(line 283748). Walkspending_animationsfrom the head whiletickCount == 0, firingCPhysicsObj::MotionDone(physics_obj, motion_id, /*arg3*/1)for each and removing action-heads (MotionState::remove_action_head) if the0x10000000bit is set. - Anim-hook driven:
CPhysicsObj::Hook_AnimDone(line 277845, addr 0x0050fda0) — registered as aCAnimHookfired byCSequence::execute_hooks(line 300780) when a specific animation FRAME carries a hook whosedirection_matches playback direction. CallsCPartArray::AnimationDone(1)->MotionTableManager::AnimationDone(1)(line 290558, addr 0x0051bce0), which incrementsanimation_counterand pops everypending_animationsentry whosetickCount <= animation_counter(decrementing the counter by each popped entry'stickCount, 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::PerformMovementcases 0-4) — callsCPhysicsObj::CheckForCompletedMotionsimmediately after everyDoMotion/DoInterpretedMotion/StopMotion/StopInterpretedMotion/StopCompletelydispatch (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_movementcallsDoInterpretedMotion/StopInterpretedMotiondirectly without a followingCheckForCompletedMotions— 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: aMotionTableManager-level animation-frame completion cascades UP into popping theCMotionInterp-level command queue. - Per-tick poll:
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 andcurr_animonly advances once the current node's frames are exhausted (viaupdate_internal's internal advance, notapricot, 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 ITSget_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_linkfound NO authored link for this style/substate pair (thelinkAnim == nullbranch inGetObjectSequence), 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'smultiply_cyclic_animation_fr(called ONLY on the same-substate fast path) operates onthis->first_cycliconward, 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:
PositionManager::adjust_offset(called every tick fromUpdatePositionInternal, 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/GetStickyObjectIDare its other exposed operations — all wrapped 1:1 throughCPhysicsObj::unstick_from_object,StopInterpolating,IsInterpolating,IsFullyConstrained,get_sticky_object_id). The named-retail decompile does not exposePositionManager::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 sameFrame* outDeltathatCPartArray::Update/CSequence::updatejust 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.- Full snap path: when
0xf74c/0xf619carries not just a motion command but also a fresh authoritativePosition(the position+movement combo case, orMoveToObject/MoveToPositioninunpack_movement's cases 6/7), the code callsCPhysicsObj::SetPositionInternal(line 283892, addr 0x00515bd0) via theMoveToManager/CPhysicsObj::MoveToObject/SetScatterPositionInternalmachinery — this is a direct authoritativePositionset (throughAdjustPosition+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_movementcase 0 (the plainInterpretedMotionState, used for ordinary walk<->run) does NOT carry aPositionat all — it only ever updates the motion command/speed and lets local dead-reckoning (CSequence-drivenupdate+ 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_offsetblended 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 callsCSequence::clear_physics(sequence)BEFORE appending the new link+cycle chain —clear_physics(line 301194, addr 0x00524d50) zeroessequence->velocityandsequence->omegaoutright, then the new link anim's OWN authored velocity/omega (if any) is added back viaadd_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_motiondirectly removes exactly that modifier's contribution (scaled by itsspeed_mod) from the still-nonzero base-cycle velocity — no full zero, noclear_physicscall, because the base cycle (e.g. still running) keeps its own velocity intact.
Stop/link animation: YES — the idle-entry is itself an authored get_link
transition clip from the current substate to the style's default substate,
exactly like any other substate-to-substate transition (Q2/Q4). There is no
"instant freeze frame"; retail plays a deceleration/stop clip.
Residual-sliding prevention: because clear_physics zeroes
sequence->velocity/omega at the moment the stop-transition is initiated
(not merely when the stop ANIMATION finishes), the apply_physics/animation
per-frame delta stops contributing translation from THAT frame onward except
whatever the stop-link-clip's own authored motion data supplies via
add_motion(seq, linkAnim, ...) immediately after the clear. So there's no
"physics keeps sliding while the stop anim plays" bug window — the only motion
during the stop-link clip is whatever the clip's OWN keyframed velocity says
(typically small/decelerating by design), and once the link clip's frames are
exhausted and playback reaches the (typically near-static) idle cyclic node,
velocity is whatever that idle cycle's own add_motion(..., cycleData, speed)
contributed (near zero for a proper "Ready"/idle motion).
Additionally: CPhysicsObj::RemoveLinkAnimations (-> CPartArray::HandleEnterWorld
which is really "flush the motion table manager's queued link anims", called
from multiple guard points: whenever physics_obj->cell == 0 inside both
DoInterpretedMotion and StopInterpretedMotion's tail, from HitGround,
LeaveGround, and from move_to_interpreted_state's caller context indirectly)
provides a hard-reset safety valve: if the object leaves the world/cell
mid-transition, any queued link-transition chain is discarded outright rather
than left dangling.
Verbatim float constants collected in this pass
| Constant | Where | Meaning |
|---|---|---|
0.000199999995f (~0.0002) |
change_cycle_speed (298276), CPhysicsObj::update_object dt-epsilon (283950 area), CPhysicsObj::SetTranslucency (279489) |
Generic "is this float effectively zero" epsilon used repeatedly across the physics/motion code — NOT walk/run-specific but the exact epsilon guarding the same-cycle speed-rescale divide-by-oldSpeed. |
2.0 (dt seconds) |
CPhysicsObj::update_object (~284009) |
Large-dt chunking threshold: any single update_object gap >= 2.0s is stepped in 1.0s UpdateObjectInternal slices to avoid one huge teleport-y integration step. |
1.0 (dt seconds) |
same function | Per-slice step size used while chunking large dt. |
1.25f |
CMotionInterp::get_state_velocity (305160) |
Sidestep speed multiplier when computing "logical state velocity" (sidestep_speed * 1.25f) — used for e.g. UI/AI queries, not the actual CSequence velocity. |
1.5f |
CMotionInterp::apply_run_to_command (305062), motion 0x6500000d (TURN) case |
Speed multiplier applied to turn commands. |
3f / -1f*3f |
CMotionInterp::apply_run_to_command, motion 0x6500000f (SIDESTEP) case |
Sidestep speed is clamped/scaled to exactly +-3.0 depending on sign of the run-rate-scaled input (with sign preserved via the x87_r7 < 0 branch). |
3.11999989f (~3.12) |
CMotionInterp::get_state_velocity (305176) |
Walk-forward (0x45000005) logical-velocity multiplier. |
4f |
CMotionInterp::get_state_velocity (305180) |
Run-forward (0x44000007) logical-velocity multiplier. |
96f |
CPhysicsObj::update_object (283974, player_distance gate) |
Distance (world units, ~yards? — needs unit confirmation) beyond which a different set_active path is taken for a non-player-object relative to the player. |
0.100000001f (0.1) |
CPhysicsObj::set_elasticity (277817) |
Elasticity clamp floor — unrelated to motion but shares the file region. |
Function/line index (quick lookup for a synthesis pass)
| Symbol | Line | Addr | Role |
|---|---|---|---|
ACSmartBox::DispatchSmartBoxEvent |
357117 | 0x005595d0 | Wire opcode switch (0xf619/0xf74c entry) |
CPhysics::SetObjectMovement (stdcall) |
271370 | 0x00509690 | Sequence-number staleness gate, dispatch |
CPhysicsObj::unpack_movement |
280179 | 0x00512040 | Lazily creates MovementManager, forwards |
MovementManager::unpack_movement |
300563 | 0x00524440 | Deserializes wire struct, 10-way type switch |
MovementManager::move_to_interpreted_state |
300259 | 0x00524170 | Lazy-create CMotionInterp, forward |
CMotionInterp::move_to_interpreted_state |
305936 | 0x005289c0 | copy_movement_from + apply_current_movement + replay actions |
InterpretedMotionState::copy_movement_from |
293301 | 0x0051e750 | Flat field overwrite (fwd/side/turn cmd+speed, style) |
CMotionInterp::apply_current_movement |
305838 | 0x00528870 | Routes to raw (local) vs interpreted (remote) path |
CMotionInterp::apply_interpreted_movement |
305713 | 0x00528600 | Issues DoInterpretedMotion per active command slot |
CMotionInterp::DoInterpretedMotion |
305575 | 0x00528360 | contact_allows_move gate, dispatch to CPhysicsObj, queue |
CPhysicsObj::DoInterpretedMotion |
276348 | 0x0050ea70 | Thin forward to CPartArray |
CPartArray::DoInterpretedMotion |
286772 | 0x00518750 | Packs MovementStruct{type=2}, calls MotionTableManager |
MotionTableManager::PerformMovement |
290906 | 0x0051c0b0 | type switch: DoObjectMotion / StopObjectMotion / StopObjectCompletely |
CMotionTable::DoObjectMotion |
300045 | 0x00523e90 | -> GetObjectSequence(force=0) |
CMotionTable::GetObjectSequence |
298636 | 0x00522860 | THE cycle-swap/append/fast-path decision |
same_sign |
298253 | 0x00522260 | Direction-unchanged test |
change_cycle_speed |
298276 | 0x00522290 | Same-cycle playback-rate rescale |
CMotionTable::get_link |
298552 | 0x00522710 | Authored transition-anim lookup |
add_motion / combine_motion / subtract_motion |
298437 / 298472 / 298492 | 0x005224b0 / 0x00522580 / 0x00522600 | Append CSequence nodes + scale velocity/omega in/out |
CSequence::append_animation |
301777 | 0x00525510 | Node creation, tail-insert, first_cyclic bump |
CSequence::clear_physics |
301194 (def not read in full but referenced) | 0x00524d50 | Zero velocity/omega |
CSequence::remove_cyclic_anims |
referenced 298701 etc | 0x00524e40 | Drop old cyclic tail before new transition |
CSequence::update |
302402 | 0x00525b80 | update_internal (has anims) OR apply_physics (no anims) |
CSequence::update_internal |
301839 | 0x005255d0 | Frame-advance (x87-obfuscated, not fully recoverable) |
CSequence::apply_physics |
300955 | 0x00524ab0 | outDelta.origin += dtvelocity; rotate(dtomega) |
CSequence::apricot |
300978 | 0x00524b40 | Trim consumed nodes from head up to first_cyclic |
CPartArray::Update |
285883 | 0x00517db0 | == CSequence::update |
CPhysicsObj::UpdatePositionInternal |
280817 | 0x00512c30 | CPartArray::Update -> PositionManager::adjust_offset -> Frame::combine -> UpdatePhysicsInternal -> process_hooks |
CPhysicsObj::UpdateObjectInternal |
283611 | 0x005156b0 | UpdatePositionInternal -> collision transition -> cached_velocity, per-tick UseTime calls |
CPhysicsObj::update_object |
283950 | 0x00515d10 | Outer per-object driver, dt clamp/chunking |
CPhysicsObj::transition |
280904 | 0x00512dc0 | Builds CTransition, sphere sweep, find_valid_position |
MotionTableManager::add_to_queue |
290854 | 0x0051bfe0 | Append to pending_animations, prune redundant links |
MotionTableManager::remove_redundant_links |
290771 | 0x0051bf20 | Collapse superseded queued link transitions |
MotionTableManager::CheckForCompletedMotions |
290645 | 0x0051be00 | Per-tick poll: pop tickCount==0 entries, fire MotionDone |
MotionTableManager::AnimationDone |
290558 | 0x0051bce0 | Anim-hook-driven pop via animation_counter |
CPhysicsObj::Hook_AnimDone |
277845 | 0x0050fda0 | CAnimHook callback -> CPartArray::AnimationDone(1) |
CPhysicsObj::MotionDone |
277856 | 0x0050fdb0 | Bridges MotionTableManager completion -> CMotionInterp queue |
MovementManager::MotionDone |
300396 | 0x005242d0 | Forward |
CMotionInterp::MotionDone |
305238 | 0x00527ec0 | Pops pending_motions head, clears stick/actions if flagged |
CMotionInterp::add_to_queue |
305032 | 0x00527b80 | Appends to CMotionInterp::pending_motions |
CMotionInterp::motions_pending |
305322 | 0x00527fe0 | pending_motions.head_ != null |
CMotionInterp::StopInterpretedMotion |
305635 | 0x00528470 | Entry for stopping a command |
CMotionTable::StopObjectMotion |
300053 | 0x00523ec0 | -> StopSequenceMotion |
CMotionTable::StopSequenceMotion |
298954 | 0x00522fc0 | Main-cycle-stop (re-enter GetObjectSequence w/ default substate) vs modifier-stop (subtract_motion) |
CMotionTable::StopObjectCompletely |
300062 | 0x00523ed0 | Iterates all modifiers + substate, stops each |
InterpretedMotionState::ApplyMotion |
293531 | 0x0051ea40 | Bookkeeping-only overwrite of forward/sidestep/turn fields |
InterpretedMotionState::RemoveMotion |
293315 | 0x0051e790 | Clears turn/sidestep/forward command back to defaults |
CPhysicsObj::RemoveLinkAnimations |
277911 | 0x0050fe20 | -> CPartArray::HandleEnterWorld: flush queued link anims |
CMotionInterp::contact_allows_move |
305471 | 0x00528240 | Gate: only creatures on solid ground can freely swap most motions |
CMotionInterp::PerformMovement |
306221 | 0x00528e80 | LOCAL-input outer dispatcher; calls CheckForCompletedMotions synchronously (NOT used by remote/wire path) |
Notes on scope / what was NOT fully resolved
CSequence::update_internal's exact per-frame arithmetic (howframe_numberadvances, exact interpolation betweenlow_frame/high_frame, and the precise mechanism by which a per-frame authored position delta fromAnimFrame/get_pos_framegets folded into the outputFrame*) 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 (seememory/feedback_bn_decomp_field_names.mdand 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 forPositionManager::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 hardSetPositionInternalwhen the wire message actually carries aPosition(MoveToObject/MoveToPosition/PositionAndMovement paths), not a distance-threshold-triggered snap layered on top of ordinary motion-command packets.