/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>
969 lines
57 KiB
Markdown
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, ¶ms) line 300644
|
|
|
|
case 7: // MoveToPosition
|
|
similar; CPhysicsObj->my_run_rate set from wire; then
|
|
MoveToManager::MoveToPosition(...) line 300659
|
|
|
|
case 8: // TurnToObject
|
|
case 9: // TurnToHeading
|
|
-> MoveToManager::TurnToHeading / handled via MoveToManager
|
|
}
|
|
```
|
|
|
|
**For Q2-Q7 (walk<->run transition on an ALREADY-moving remote entity), case 0
|
|
(`InterpretedMotionState::UnPack` + `move_to_interpreted_state`) is the relevant
|
|
path.** This is opcode 0xF619/0xF74C's "type 0" sub-message — same struct shape
|
|
as the client's own `RawMotionState`/interp state, containing
|
|
`current_style`, `forward_command`, `forward_speed`, `sidestep_command/speed`,
|
|
`turn_command/speed`, plus a list of pending server "actions"
|
|
(`context_id`/`action_stamp` pairs used for jump-charge/attack acknowledgement,
|
|
NOT used for normal walk/run).
|
|
|
|
`MovementManager::move_to_interpreted_state` (line 300259, addr 0x00524170):
|
|
```
|
|
void move_to_interpreted_state(MovementManager* this, InterpretedMotionState* ims)
|
|
{
|
|
if (motion_interpreter == null) {
|
|
motion_interpreter = CMotionInterp::Create(physics_obj, weenie_obj)
|
|
CMotionInterp::enter_default_state(motion_interpreter)
|
|
}
|
|
CMotionInterp::move_to_interpreted_state(motion_interpreter, ims) line 300272
|
|
}
|
|
```
|
|
|
|
`CMotionInterp::move_to_interpreted_state` (line 305936, addr 0x005289c0) — THE
|
|
entry point that turns a wire InterpretedMotionState into an actual motion-table
|
|
transition:
|
|
```
|
|
int32 move_to_interpreted_state(CMotionInterp* this, InterpretedMotionState* ims)
|
|
{
|
|
if (physics_obj == null) return 0
|
|
this->raw_state.current_style = ims->current_style
|
|
CPhysicsObj::interrupt_current_movement(physics_obj)
|
|
bool wasJumpAllowed = CMotionInterp::motion_allows_jump(this, interpreted_state.forward_command)
|
|
InterpretedMotionState::copy_movement_from(&this->interpreted_state, ims) // <-- OVERWRITES
|
|
// forward/side/turn
|
|
// command+speed wholesale,
|
|
// line 293301
|
|
CMotionInterp::apply_current_movement(this, /*forceReapply=*/1, /*jumpFlag*/ -(...)) line 305949
|
|
|
|
// then replay any queued server "actions" (jump charge etc.) whose
|
|
// action_stamp is newer than server_action_stamp — sequence-wraparound
|
|
// compare identical in shape to the SetObjectMovement 0x7fff test above
|
|
for (action in ims->actions) {
|
|
if (newer(action.stamp, this->server_action_stamp)) {
|
|
this->server_action_stamp = action.stamp
|
|
CMotionInterp::DoInterpretedMotion(this, action.motion, ¶ms)
|
|
}
|
|
}
|
|
return 1
|
|
}
|
|
```
|
|
|
|
**KEY: `copy_movement_from` is a flat field-by-field OVERWRITE of the
|
|
InterpretedMotionState (forward_command, forward_speed, sidestep_*, turn_*,
|
|
current_style) — there is no "diff the old vs new command" step here.** The
|
|
actual "is this the same cycle or a new one" decision happens ONE LEVEL DOWN,
|
|
inside `CMotionTable::GetObjectSequence`, when `apply_current_movement` ->
|
|
`apply_interpreted_movement` -> `DoInterpretedMotion` is called with the new
|
|
`forward_command`/`forward_speed`.
|
|
|
|
---
|
|
|
|
## Q2 — TRANSITION: walk<->run while already moving
|
|
|
|
`CMotionInterp::apply_current_movement` (line 305838, addr 0x00528870):
|
|
```
|
|
void apply_current_movement(CMotionInterp* this, int forceFlag, int jumpFlag)
|
|
{
|
|
if (physics_obj == null || !initted) return
|
|
isPlayerOrNoWeenie = (weenie_obj == null) || weenie_obj->IsThePlayer()
|
|
if (isPlayerOrNoWeenie && CPhysicsObj::movement_is_autonomous(physics_obj))
|
|
return apply_raw_movement(this, forceFlag, jumpFlag) // LOCAL player path
|
|
return apply_interpreted_movement(this, forceFlag, jumpFlag) // REMOTE entity path
|
|
}
|
|
```
|
|
`movement_is_autonomous` just returns `physics_obj->last_move_was_autonomous`
|
|
(set by `SetObjectMovement` above — for a genuinely-remote object this flag is
|
|
always false relative to the LOCAL viewer, so remote players/monsters always
|
|
take the `apply_interpreted_movement` branch.)
|
|
|
|
`CMotionInterp::apply_interpreted_movement` (line 305713, addr 0x00528600) — the
|
|
per-command dispatcher that turns the bookkeeping InterpretedMotionState fields
|
|
back into individual `DoInterpretedMotion` calls:
|
|
```
|
|
void apply_interpreted_movement(CMotionInterp* this, int a, int b)
|
|
{
|
|
if physics_obj == null: return
|
|
if interpreted_state.forward_command == RUN_FORWARD (0x44000007):
|
|
this->my_run_rate = interpreted_state.forward_speed // caches server-echoed run rate
|
|
|
|
DoInterpretedMotion(this, interpreted_state.current_style, {}) // style (stance) first
|
|
|
|
if (!contact_allows_move(this, interpreted_state.forward_command)) {
|
|
DoInterpretedMotion(this, MOTION_FALLING /*0x40000015*/, {})
|
|
} else if (standing_longjump) {
|
|
DoInterpretedMotion(this, READY_STANCE /*0x41000003*/, {})
|
|
StopInterpretedMotion(this, LONGJUMP /*0x6500000f*/, {})
|
|
} else {
|
|
DoInterpretedMotion(this, interpreted_state.forward_command, {}) // <-- WALK/RUN COMMAND
|
|
if interpreted_state.sidestep_command == 0:
|
|
StopInterpretedMotion(this, SIDESTEP /*0x6500000f*/, {})
|
|
else:
|
|
DoInterpretedMotion(this, interpreted_state.sidestep_command, {})
|
|
}
|
|
|
|
if interpreted_state.turn_command != 0:
|
|
DoInterpretedMotion(this, interpreted_state.turn_command, {})
|
|
return // early return — no idle-stop check runs this frame
|
|
if (StopInterpretedMotion(physics_obj, TURN /*0x6500000d*/, {}) == 0)
|
|
add_to_queue(this, ctx=0, READY_STANCE, tickCount)
|
|
}
|
|
```
|
|
So a wire "run instead of walk" update decays into exactly one
|
|
`CMotionInterp::DoInterpretedMotion(this, RUN_FORWARD, {speed = new run speed})`
|
|
call (or `WALK_FORWARD`) — a single call with the SAME semantics as the local
|
|
input path, not a special "speed-changed" fast path at this layer.
|
|
|
|
`CMotionInterp::DoInterpretedMotion` (line 305575, addr 0x00528360):
|
|
```
|
|
uint32 DoInterpretedMotion(CMotionInterp* this, uint32 motion, MovementParameters* p)
|
|
{
|
|
if physics_obj == null: return 8
|
|
if (contact_allows_move(this, motion)) {
|
|
if (standing_longjump && motion in {JUMP-ish set}) goto label_528440 (bail to
|
|
ApplyMotion-only path)
|
|
if motion == 0x40000011 /* some "cancel" motion */:
|
|
CPhysicsObj::RemoveLinkAnimations(physics_obj) // <-- flush queued link anims
|
|
result = CPhysicsObj::DoInterpretedMotion(physics_obj, motion, p) // -> CPartArray -> MotionTableManager
|
|
if result == 0:
|
|
jumpAllowed = ...
|
|
add_to_queue(this, p->context_id, motion, jumpAllowed)
|
|
if (flag bit 0x40 of context set): InterpretedMotionState::ApplyMotion(&interpreted_state, motion, p)
|
|
} else if (motion & 0x10000000) == 0:
|
|
label_528440:
|
|
if (flag bit 0x40 set): InterpretedMotionState::ApplyMotion(...)
|
|
result = 0
|
|
else:
|
|
result = 0x24 // motion rejected (e.g. mid-air command not allowed)
|
|
|
|
if (physics_obj != null && physics_obj->cell == 0)
|
|
CPhysicsObj::RemoveLinkAnimations(physics_obj) // detached-from-world guard
|
|
return result
|
|
}
|
|
```
|
|
`CPhysicsObj::DoInterpretedMotion` -> `CPartArray::DoInterpretedMotion` -> packs
|
|
a `MovementStruct{type=InterpretedCommand}` and calls
|
|
`MotionTableManager::PerformMovement`, which for `InterpretedCommand` calls:
|
|
|
|
```
|
|
if (CMotionTable::DoObjectMotion(table, motion, &state, &sequence, speed, &outTicks))
|
|
MotionTableManager::add_to_queue(this, motion, outTicks, sequence) // queues in
|
|
// pending_animations
|
|
// (DIFFERENT list
|
|
// from CMotionInterp's
|
|
// pending_motions!)
|
|
```
|
|
`CMotionTable::DoObjectMotion` is a thin wrapper for
|
|
`CMotionTable::GetObjectSequence(table, motion, state, seq, speed, outTicks, /*force*/0)`.
|
|
|
|
### `CMotionTable::GetObjectSequence` — THE cycle-swap decision (line 298636, addr 0x00522860)
|
|
|
|
This is the true state machine that decides append-vs-replace-vs-fast-path. Given
|
|
`new_substate = motion & 0xffffff` bucketed by which high bit is set on `motion`:
|
|
|
|
**Bit `0x40000000` set — a normal "cycle" motion (this is what WALK/RUN commands
|
|
carry, e.g. `0x44000007` RunForward, `0x45000005` WalkForward):**
|
|
```
|
|
cycleData = cycles.lookup((style<<16) | (new_substate & 0xffffff))
|
|
if cycleData != null && CMotionTable::is_allowed(table, new_substate, cycleData, state):
|
|
|
|
// *** THE SAME-CYCLE FAST PATH ***
|
|
if (new_substate == state->substate // SAME logical
|
|
&& same_sign(new_speed, state->substate_mod) // command (walk OR run
|
|
&& CSequence::has_anims(sequence)) { // stays walk, or run stays
|
|
// run — direction unchanged)
|
|
// AND a cycle is already
|
|
// playing
|
|
change_cycle_speed(sequence, cycleData, state->substate_mod, new_speed) // rescale playback rate
|
|
subtract_motion(sequence, cycleData, state->substate_mod) // remove OLD velocity contribution
|
|
combine_motion(sequence, cycleData, new_speed) // add NEW velocity contribution
|
|
state->substate_mod = new_speed
|
|
return 1 // <-- NO new CSequence nodes appended. Same AnimSequenceNode
|
|
// keeps playing; only its playback-rate + the CSequence's
|
|
// cached velocity/omega vectors change.
|
|
}
|
|
|
|
// *** DIFFERENT SUBSTATE (e.g. walk -> run is usually a DIFFERENT
|
|
// substate id, not same-sign-same-substate) — LINK TRANSITION PATH ***
|
|
linkAnim = CMotionTable::get_link(table, state->style, state->substate,
|
|
state->substate_mod, new_substate, new_speed)
|
|
if (linkAnim == null || same_sign(new_speed, state->substate_mod) == 0) {
|
|
// no direct link authored, OR direction reversed: route through the
|
|
// style's registered "default"/rest substate as an intermediate hop
|
|
defaultSubstate = style_defaults[state->style]
|
|
linkAnim = get_link(style, state->substate, state->substate_mod, defaultSubstate, 1.0)
|
|
linkAnim2 = get_link(style, defaultSubstate, 1.0, new_substate, new_speed)
|
|
}
|
|
|
|
CSequence::clear_physics(sequence) // zero cached velocity/omega — see Q6/Q7
|
|
CSequence::remove_cyclic_anims(sequence) // drop any still-looping cycle node(s)
|
|
add_motion(sequence, linkAnim, 1.0-or-substate_mod) // append the transition ("link") anim node(s)
|
|
add_motion(sequence, linkAnim2, new_speed) // (if double-hop via default state)
|
|
add_motion(sequence, cycleData, new_speed) // append the NEW cyclic anim, marked cyclic
|
|
state->substate = new_substate
|
|
state->substate_mod = new_speed
|
|
CMotionTable::re_modify(table, sequence, state) // re-apply any active modifiers (e.g. sidestep)
|
|
// on top of the new chain
|
|
*outTicks = cycleData->action_head + linkAnim.num_anims + linkAnim2.num_anims - 1
|
|
return 1
|
|
```
|
|
|
|
**Answering the prompt's explicit sub-question (a):** DoInterpretedMotion on a
|
|
speed change does **NOT always reuse the same cycle**. It depends on whether the
|
|
new command maps to the SAME `substate` id as the currently-playing one:
|
|
- Speed-only change to the SAME substate (e.g. WalkForward speed 0.6 ->
|
|
WalkForward speed 1.0, or RunForward at any two different `forward_speed`
|
|
values) hits the **fast path**: `change_cycle_speed` + `subtract_motion`/
|
|
`combine_motion` — same `AnimSequenceNode` object, just re-timed and
|
|
re-weighted. No new node, no restart.
|
|
- **Walk<->Run is a substate CHANGE (`0x45000005` WalkForward vs
|
|
`0x44000007` RunForward are different substate ids)**, so it does NOT hit
|
|
the fast path. It goes through the **link-transition path**: `get_link`
|
|
looks up an authored transition animation (a short blend clip, e.g.
|
|
walk-to-run or run-to-walk) between the two substates; that link node(s)
|
|
are appended to the sequence via `add_motion`, followed by the new cyclic
|
|
node. The OLD cyclic node is dropped (`remove_cyclic_anims`). Playback then
|
|
proceeds: link anim plays first (non-cyclic, finite frames), and once it
|
|
completes the `CSequence::update_internal` advance mechanism moves
|
|
`curr_anim` forward in the `anim_list` to the next node — the new cyclic
|
|
walk/run anim — automatically (see Q4).
|
|
|
|
**Sub-question (b): the CSequence node list.** `CSequence::anim_list` is a
|
|
doubly-linked list (`DLListBase` of `AnimSequenceNode`), NOT a single
|
|
"QueuedAnimations" array. `add_motion` -> `CSequence::append_animation` (line
|
|
301777) creates one new `AnimSequenceNode` per `MotionData::anims[i]` entry and
|
|
`DLListBase::InsertAfter`s it at the tail. `this->first_cyclic` marks where the
|
|
cyclic (looping) portion of the list begins; `remove_cyclic_anims` trims
|
|
everything from `first_cyclic` onward when a new transition starts (so
|
|
`clear_physics` + `remove_cyclic_anims` together mean: "keep any link anim
|
|
that's mid-playback [it's before first_cyclic], but throw away the old loop").
|
|
`curr_anim` points at the node currently being played; `CSequence::update`
|
|
advances `frame_number` within `curr_anim` and, in `apricot()`, walks
|
|
`curr_anim` forward through the list once frames are exhausted for a node,
|
|
discarding fully-consumed non-cyclic nodes from the front of the list up to
|
|
`first_cyclic`.
|
|
|
|
**Sub-question: is there blending?** No cross-fade/blend in the graphics sense.
|
|
It's sequential: `link_anim -> cyclic_anim`, back-to-back play, and the
|
|
crossover is a hard node-swap at frame boundary (see Q4). "Blending" in this
|
|
codebase means the `CSequence.velocity`/`omega` accumulators (float vectors)
|
|
are algebraically combined (`combine_motion`/`subtract_motion`/`add_motion`
|
|
add or subtract scaled contributions) — that's a physics-level blend of
|
|
velocity, not a skeletal pose blend.
|
|
|
|
**Sub-question: is there an immediate speed change?** Only in the same-substate
|
|
fast path (`change_cycle_speed`+`subtract_motion`+`combine_motion` all happen
|
|
synchronously inside `GetObjectSequence`, i.e., on the SAME frame the wire
|
|
message is processed — no interpolation of speed itself). For walk<->run
|
|
(different substate), the VISIBLE speed change is gated behind the link anim's
|
|
playback duration — velocity is whatever `CSequence.velocity` currently holds
|
|
(the link anim's own authored velocity/omega, added via `add_motion`), and only
|
|
once the cyclic node becomes current does the full run/walk cyclic velocity
|
|
apply.
|
|
|
|
### `same_sign` (line 298253, addr 0x00522260) — verbatim
|
|
```
|
|
int same_sign(float a, float b) {
|
|
// true (1) if a and b are both >=0 or both <0 (treats 0 as non-negative);
|
|
// this is the "is direction unchanged" test used to gate the same-cycle
|
|
// fast path and to decide whether get_link needs a sign-aware lookup.
|
|
return !(a<0) == !(b<0); // (pseudocode paraphrase of the FCMP branches)
|
|
}
|
|
```
|
|
|
|
### `change_cycle_speed` (line 298276, addr 0x00522290) — verbatim constant
|
|
```
|
|
void change_cycle_speed(CSequence* seq, MotionData* cyc, float oldSpeed, float newSpeed) {
|
|
if (fabs(oldSpeed) >= 0.000199999995f) // EPSILON = ~0.0002
|
|
CSequence::multiply_cyclic_animation_fr(seq, newSpeed / oldSpeed); // rescale framerate
|
|
else if (fabs(newSpeed) >= 0.000199999995f)
|
|
CSequence::multiply_cyclic_animation_fr(seq, 0.0f); // freeze (old speed ~0)
|
|
// else: both ~0, no-op
|
|
}
|
|
```
|
|
This is literally "new playback rate multiplier = newSpeed / oldSpeed" applied
|
|
to every node from `sequence->first_cyclic` onward
|
|
(`AnimSequenceNode::multiply_framerate`, line 302425) — so a walk<->walk speed
|
|
change (same substate) scales animation playback speed proportionally to the
|
|
commanded speed ratio, and ALSO swaps `low_frame`/`high_frame` if the new
|
|
multiplier is negative (playing the cycle backward).
|
|
|
|
---
|
|
|
|
## Q3 — PENDING_MOTIONS / MOTION_DONE lifecycle
|
|
|
|
**There are TWO distinct pending-queues, easy to conflate:**
|
|
|
|
1. **`CMotionInterp::pending_motions`** (singly-linked `LListData`, fields:
|
|
`[next, context_id, motion, jumpAllowedFlag]`). Owner: `CMotionInterp`.
|
|
Appended by `CMotionInterp::add_to_queue` (line 305032, addr 0x00527b80) —
|
|
called from `DoInterpretedMotion` (line 305607), `StopInterpretedMotion`
|
|
(line 305657), `apply_interpreted_movement`'s idle-stop path (line 305775),
|
|
and `StopCompletely` (line 305227). Popped ONLY by
|
|
`CMotionInterp::MotionDone` (line 305238, addr 0x00527ec0):
|
|
```
|
|
void MotionDone(CMotionInterp* this, int arg2) {
|
|
if (physics_obj == null) return
|
|
head = pending_motions.head_
|
|
if (head != null) {
|
|
if (head->motion & 0x10000000) { // this queued motion carried
|
|
// a server "action" (jump
|
|
// charge etc.)
|
|
CPhysicsObj::unstick_from_object(physics_obj)
|
|
InterpretedMotionState::RemoveAction(&interpreted_state)
|
|
RawMotionState::RemoveAction(&raw_state)
|
|
}
|
|
pop head off pending_motions (delete node)
|
|
}
|
|
}
|
|
```
|
|
`CMotionInterp::motions_pending()` (line 305322) == `pending_motions.head_ != null`.
|
|
**`context_id`/`action_stamp` on this queue is used for jump-charge and
|
|
other server-acknowledged "actions", NOT for ordinary walk/run — ordinary
|
|
`DoInterpretedMotion` calls still push a node here (so `motions_pending()`
|
|
reflects "any interpreted motion is mid-flight"), but nothing about
|
|
walk<->run reads the `context_id`/action semantics.**
|
|
|
|
2. **`MotionTableManager::pending_animations`** (doubly-linked `DLListBase`,
|
|
fields: `[motion_id, tickCount]`), plus `MotionTableManager::animation_counter`
|
|
(running decrement counter). Owner: `MotionTableManager` (one per
|
|
`CPartArray`, i.e. per rendered mesh/skeleton — this is the ANIMATION-frame
|
|
-level completion tracker, distinct from #1's motion-command-level tracker).
|
|
Appended by `MotionTableManager::add_to_queue` (line 290854, addr
|
|
0x0051bfe0) every time `GetObjectSequence` succeeds, storing the `outTicks`
|
|
value it returned (how many more discrete animation "steps"/frames worth of
|
|
non-cyclic content remain before this motion is fully consumed). Also
|
|
immediately calls `remove_redundant_links` (line 290771) to prune
|
|
already-queued-but-superseded link-transition entries (see Q2 note on
|
|
walk<->run spam).
|
|
|
|
**Consumption / popping — TWO drivers:**
|
|
- **Per-tick poll:** `MotionTableManager::CheckForCompletedMotions` (line
|
|
290645, addr 0x0051be00), called every physics tick via
|
|
`CPartArray::HandleMovement` -> `MotionTableManager::UseTime` (alias for
|
|
`CheckForCompletedMotions`, line 290845) from
|
|
`CPhysicsObj::UpdateObjectInternal` (line 283748). Walks
|
|
`pending_animations` from the head while `tickCount == 0`, firing
|
|
`CPhysicsObj::MotionDone(physics_obj, motion_id, /*arg3*/1)` for each and
|
|
removing action-heads (`MotionState::remove_action_head`) if the
|
|
`0x10000000` bit is set.
|
|
- **Anim-hook driven:** `CPhysicsObj::Hook_AnimDone` (line 277845, addr
|
|
0x0050fda0) — registered as a `CAnimHook` fired by
|
|
`CSequence::execute_hooks` (line 300780) when a specific animation FRAME
|
|
carries a hook whose `direction_` matches playback direction. Calls
|
|
`CPartArray::AnimationDone(1)` -> `MotionTableManager::AnimationDone(1)`
|
|
(line 290558, addr 0x0051bce0), which increments `animation_counter` and
|
|
pops every `pending_animations` entry whose `tickCount <= animation_counter`
|
|
(decrementing the counter by each popped entry's `tickCount`, i.e. a
|
|
running-total consumption model, not a strict per-frame countdown).
|
|
- **Synchronous, post-dispatch:** `CMotionInterp::PerformMovement` (line
|
|
306221, addr 0x00528e80) — the outer entry used by the LOCAL player's raw
|
|
input path (`MovementManager::PerformMovement` cases 0-4) — calls
|
|
`CPhysicsObj::CheckForCompletedMotions` immediately after every
|
|
`DoMotion`/`DoInterpretedMotion`/`StopMotion`/`StopInterpretedMotion`/
|
|
`StopCompletely` dispatch (line 306234/241/248/255/262), so a
|
|
zero-duration motion completes in the SAME frame it was issued rather
|
|
than waiting for the next tick. This path is NOT used by the wire/remote
|
|
entry (`CMotionInterp::apply_interpreted_movement` calls
|
|
`DoInterpretedMotion`/`StopInterpretedMotion` directly without a following
|
|
`CheckForCompletedMotions` — the remote entity therefore only gets its
|
|
completions serviced by the per-tick poll and the anim-hook path, not the
|
|
synchronous one).
|
|
|
|
`CPhysicsObj::MotionDone(physics_obj, motion_id, arg3)` (line 277856, addr
|
|
0x0050fdb0) -> `MovementManager::MotionDone` (line 300396) ->
|
|
`CMotionInterp::MotionDone` (line 305238, described in #1 above). **This is
|
|
the bridge between the two queues**: a `MotionTableManager`-level
|
|
animation-frame completion cascades UP into popping the
|
|
`CMotionInterp`-level command queue.
|
|
|
|
**Callback / state change on completion:** popping `pending_motions` only (a)
|
|
optionally clears the "stuck to object" state + removes a pending server action
|
|
if the popped node had the `0x10000000` "carries an action" bit, and (b) frees
|
|
the node. It does NOT itself touch velocity, `substate`, or the `CSequence`
|
|
node list — those were already mutated synchronously back when
|
|
`GetObjectSequence` ran (at command-ISSUE time, not command-COMPLETE time).
|
|
**The animation-frame-level completion (`AnimationDone`/`CheckForCompletedMotions`)
|
|
is what actually matters for gameplay feel: it's what lets a queued
|
|
non-cyclic link anim naturally hand off to the next queued node (see Q4) and
|
|
what lets a "jump" or other single-shot server action be acknowledged as
|
|
finished.**
|
|
|
|
---
|
|
|
|
## Q4 — CYCLE SWAP: frame index carryover vs restart vs link
|
|
|
|
Per `CSequence::append_animation` (line 301777, addr 0x00525510):
|
|
```
|
|
void append_animation(CSequence* this, AnimData* animData) {
|
|
node = new AnimSequenceNode(animData)
|
|
if (!node->has_anim()) { delete node immediately; return } // degenerate/empty motion, skip
|
|
DLListBase::InsertAfter(&anim_list, node, anim_list.tail_) // always appended at TAIL
|
|
this->first_cyclic = node // *** every appended node
|
|
// becomes the new
|
|
// first_cyclic marker
|
|
// until superseded ***
|
|
if (curr_anim == null) { // sequence was idle/empty
|
|
curr_anim = anim_list.head_
|
|
frame_number = curr_anim->get_starting_frame()
|
|
}
|
|
// if curr_anim was already non-null (something mid-playback), it is
|
|
// left untouched — the newly appended node just waits at the tail.
|
|
}
|
|
```
|
|
So **appending never resets `frame_number` for whatever's currently playing.**
|
|
The frame index of the CURRENTLY playing node (the link anim, or the old cycle
|
|
if it's still `curr_anim`) is untouched.
|
|
|
|
`CSequence::update` (line 302402, addr 0x00525b80):
|
|
```
|
|
void update(CSequence* this, double dt, Frame* outDelta) {
|
|
if (anim_list.head_ != null) {
|
|
CSequence::update_internal(this, dt, &curr_anim, &frame_number, outDelta)
|
|
CSequence::apricot(this) // list-trim housekeeping (below)
|
|
} else if (outDelta != null) {
|
|
CSequence::apply_physics(this, outDelta, dt, dt) // PURE velocity integration,
|
|
// no animation nodes at all
|
|
}
|
|
}
|
|
```
|
|
`update_internal` (line 301839, addr 0x005255d0) is heavily x87-obfuscated in
|
|
this decompile (unresolvable float compares/branches show as raw
|
|
`/* unimplemented */` FPU op comments) — the BN decompiler could not fully
|
|
recover its control flow. What IS recoverable: it advances `frame_number`
|
|
within `curr_anim` by `dt * anim->framerate`-derived amount, and once a node's
|
|
frames are exhausted it walks `curr_anim` to `AnimSequenceNode::GetNext(...)`
|
|
(confirmed indirectly via `apricot`'s cleanup logic below and via
|
|
`CSequence::execute_hooks`/`multiply_cyclic_animation_fr` operating on
|
|
"`first_cyclic` onward" — i.e., cyclic nodes loop in place by wrapping
|
|
`frame_number`, non-cyclic/link nodes advance `curr_anim` to the next list
|
|
node when frames are exhausted).
|
|
|
|
`CSequence::apricot` (line 300978, addr 0x00524b40) — the list-trim called every
|
|
`update()`:
|
|
```
|
|
void apricot(CSequence* this) {
|
|
i = (anim_list.head_ != null) ? adjustedHead : null
|
|
if (i != curr_anim) {
|
|
while (i != first_cyclic) {
|
|
// unlink node i from anim_list (both directions), delete it,
|
|
// then advance i to the new head
|
|
... unlink + delete ...
|
|
i = new head
|
|
if (i == curr_anim) break
|
|
}
|
|
}
|
|
}
|
|
```
|
|
i.e., **once `curr_anim` has moved past the head of the list (a node finished
|
|
playing), `apricot` deletes every now-stale node from `anim_list.head_` up to
|
|
(but not including) `first_cyclic`.** This is standard "consume finished
|
|
one-shot link anims off the front of the queue" behavior.
|
|
|
|
**Direct answer:** the frame index does **NOT carry over between the OLD cycle
|
|
and the NEW cycle** — they are different `AnimSequenceNode` objects wrapping
|
|
different `CAnimation` data with independently-tracked start frames
|
|
(`AnimSequenceNode::get_starting_frame()`). What DOES carry over/continue
|
|
smoothly is:
|
|
- the **link animation plays out fully first** (its own authored frame range,
|
|
from `get_starting_frame()` to its end), because it was appended to the
|
|
tail and `curr_anim` only advances once the current node's frames are
|
|
exhausted (via `update_internal`'s internal advance, not `apricot`, which
|
|
is just cleanup).
|
|
- once the link anim's frames are exhausted, playback naturally proceeds to
|
|
the next node in `anim_list` (the newly appended cyclic walk/run node),
|
|
which starts fresh at ITS `get_starting_frame()`.
|
|
- So the transition literally IS the link animation: **walk -> run uses an
|
|
authored transition clip in between**; there is no cross-fade of the walk
|
|
cycle's frame position into the run cycle's frame position. The retail art
|
|
pipeline authors these link/transition clips specifically so this hard
|
|
swap looks continuous.
|
|
- If `get_link` found NO authored link for this style/substate pair (the
|
|
`linkAnim == null` branch in `GetObjectSequence`), the code instead hops
|
|
through the style's "default" (idle/ready) substate as an intermediate —
|
|
two link anims chained — rather than doing a raw cut.
|
|
- `change_cycle_speed`'s `multiply_cyclic_animation_fr` (called ONLY on the
|
|
same-substate fast path) operates on `this->first_cyclic` onward, i.e., it
|
|
re-times whatever is the CURRENT cyclic node in place — it does not touch
|
|
frame_number's absolute position within that node, only its rate of
|
|
advance, so a walk-speed-change (not walk<->run) preserves the current
|
|
frame's phase, just plays faster/slower/backward from there.
|
|
|
|
---
|
|
|
|
## Q5 — POSITION DRIVE between inbound packets
|
|
|
|
Confirmed by tracing `CPhysicsObj::update_object` -> `UpdateObjectInternal` ->
|
|
`UpdatePositionInternal` -> `CPartArray::Update` -> `CSequence::update`:
|
|
|
|
```
|
|
CPhysicsObj::update_object(CPhysicsObj* this) line 283950, addr 0x00515d10
|
|
{
|
|
... skip if parented/no-cell/frozen ...
|
|
dt = Timer::cur_time - this->update_time
|
|
if dt < 0.000199999995f: return // EPSILON, same constant as change_cycle_speed
|
|
if dt < 2.0:
|
|
UpdatePositionInternal-chain for the whole dt in one call
|
|
else:
|
|
// clamp/step: chunk into <=1.0s steps while remaining dt >= 2.0,
|
|
// then one final UpdateObjectInternal(remainder) call — prevents a
|
|
// huge single-frame teleport after e.g. a stall/loading hitch
|
|
while (remaining >= 2.0) { UpdateObjectInternal(this, 1.0); remaining -= 1.0 }
|
|
UpdateObjectInternal(this, remaining)
|
|
}
|
|
|
|
CPhysicsObj::UpdateObjectInternal(CPhysicsObj* this, float dt) line 283611, addr 0x005156b0
|
|
{
|
|
... early-outs for ethereal/off-world states, still runs particle/script update ...
|
|
if (this->cell != 0) {
|
|
var deltaFrame = {identity}
|
|
UpdatePositionInternal(this, dt, &deltaFrame) // <-- computes the candidate move
|
|
if (has spheres / real collision geometry) {
|
|
if (deltaFrame == zero-delta) {
|
|
set_frame(this, &deltaFrame); cached_velocity = 0
|
|
} else {
|
|
heading update (velocity-derived or state-flag-derived)
|
|
CTransition* result = CPhysicsObj::transition(this, &m_position, &deltaFrame, 0) // <-- FULL
|
|
// COLLISION
|
|
// SWEEP,
|
|
// same
|
|
// machinery
|
|
// as local
|
|
// player
|
|
// movement
|
|
if (result == null) {
|
|
set_frame(this, &deltaFrame) // blocked entirely -> stays put, but frame still applied??
|
|
// (this branch means find_valid_position failed to
|
|
// produce a transition object; effectively a no-collision
|
|
// passthrough for objects without real spheres)
|
|
cached_velocity = 0
|
|
} else {
|
|
cached_velocity = (result->sphere_path.curr_pos - m_position) / dt // ACTUAL POST-COLLISION
|
|
// velocity, NOT the
|
|
// raw commanded one
|
|
SetPositionInternal(this, result)
|
|
}
|
|
}
|
|
} else {
|
|
// no collision spheres on this part array: apply frame directly, no sweep
|
|
set_frame(this, &deltaFrame); cached_velocity = 0
|
|
}
|
|
DetectionManager / TargetManager / MovementManager::UseTime / CPartArray::HandleMovement
|
|
(== MotionTableManager::UseTime
|
|
== CheckForCompletedMotions) /
|
|
PositionManager::UseTime
|
|
}
|
|
}
|
|
|
|
CPhysicsObj::UpdatePositionInternal(CPhysicsObj* this, float dt, Frame* outDelta) line 280817, addr 0x00512c30
|
|
{
|
|
if (!ethereal-ish state bit): CPartArray::Update(part_array, dt, outDelta) // <-- FILLS outDelta
|
|
// via CSequence::update
|
|
if (position_manager != null): PositionManager::adjust_offset(position_manager, outDelta, dt)
|
|
// (server position-correction blend, see Q6)
|
|
Frame::combine(outDelta, &this->m_position.frame, outDelta) // outDelta = currentFrame (+) outDelta
|
|
if (!ethereal-ish): CPhysicsObj::UpdatePhysicsInternal(this, dt, outDelta) // gravity/step physics on top
|
|
CPhysicsObj::process_hooks(this) // <-- fires queued CAnimHooks (incl. AnimDone) EVERY TICK, post-position
|
|
}
|
|
|
|
CPartArray::Update(CPartArray* this, float dt, Frame* outDelta) line 285883, addr 0x00517db0
|
|
{
|
|
CSequence::update(&this->sequence, dt, outDelta) // exactly the branch described in Q4:
|
|
// animation-node-consumption path OR
|
|
// pure apply_physics(velocity*dt) fallback
|
|
}
|
|
```
|
|
|
|
**Direct answer: BOTH, and they are the SAME code path, not two competing
|
|
sources.** `CSequence::update` chooses between:
|
|
(a) **animation-node consumption** (`update_internal`) when `anim_list` is
|
|
non-empty — this advances frames AND, per-node, the per-frame position
|
|
delta baked into the `AnimFrame` data (`get_pos_frame`/`get_part_frame`)
|
|
contributes to the produced `outDelta` Frame (the x87-obscured part of
|
|
`update_internal`, but its role is confirmed by `AnimSequenceNode::get_pos_frame`
|
|
/ `get_part_frame` existing specifically to fetch per-frame authored
|
|
pose+position data), and
|
|
(b) **`apply_physics`** (pure `outDelta.origin += dt * this->velocity;
|
|
outDelta.rotate(dt * this->omega)`) when `anim_list` is EMPTY (i.e. a
|
|
pure-interpreted-velocity idle/moving state with no queued transition
|
|
animations left) — this is the steady-state "walking/running in a
|
|
straight line between server packets" case for a LOOPING cyclic anim once
|
|
its own list bookkeeping considers it "done producing new nodes" — but
|
|
note `has_anims()` / `anim_list.head_ != null` is true whenever there's
|
|
ANY node (including the still-looping cyclic one), so in practice, for a
|
|
normal walk/run cycle, path (a) is what's active essentially always;
|
|
path (b) is the true-idle / "no motion data at all, just raw velocity"
|
|
fallback (e.g. after `StopCompletely` clears everything, or for
|
|
objects that were never given a motion table).
|
|
Either way, the output Frame delta is what feeds `Frame::combine` against the
|
|
CURRENT position, and the combined candidate then goes through the FULL
|
|
`CPhysicsObj::transition` collision sweep — remote entities are
|
|
collision-checked every tick exactly like the local player, they are not
|
|
simply "teleported" along a straight line. `cached_velocity` (used for e.g.
|
|
UI/physics queries, NOT for driving the next tick's move — the next tick
|
|
re-derives everything from `CSequence` state) is the ACTUAL post-collision
|
|
displacement/dt, which can differ from the commanded interpreted velocity if
|
|
a wall was hit.
|
|
|
|
---
|
|
|
|
## Q6 — CORRECTION: reconciling inbound position updates
|
|
|
|
Two independent correction paths were located; both are called from
|
|
`UpdatePositionInternal`/its callers, gated by whether the wire message
|
|
carried a full `Position` update or just a motion-command update:
|
|
|
|
1. **`PositionManager::adjust_offset`** (called every tick from
|
|
`UpdatePositionInternal`, line 280857) — blends a stored "we're behind
|
|
where we should be" offset into the per-tick delta over time, i.e. a
|
|
position-manager-owned soft-correction/interpolation smoothing layer
|
|
(`PositionManager::UnStick`/`StopInterpolating`/`IsInterpolating`/
|
|
`IsFullyConstrained`/`GetStickyObjectID` are its other exposed operations —
|
|
all wrapped 1:1 through `CPhysicsObj::unstick_from_object`,
|
|
`StopInterpolating`, `IsInterpolating`, `IsFullyConstrained`,
|
|
`get_sticky_object_id`). The named-retail decompile does not expose
|
|
`PositionManager::adjust_offset`'s internal body in this file (its class
|
|
implementation lives outside the traced call chain reached in this pass);
|
|
what's confirmed is its CALL SITE and its INPUT/OUTPUT contract: it mutates
|
|
the same `Frame* outDelta` that `CPartArray::Update`/`CSequence::update`
|
|
just wrote, i.e. it's a correction applied ON TOP OF the
|
|
animation/velocity-driven delta, before that delta is combined with
|
|
current position and swept for collision. This is the retail equivalent of
|
|
"dead-reckoning error absorbed gradually into the next frame's move" rather
|
|
than a hard position snap.
|
|
2. **Full snap path**: when `0xf74c`/`0xf619` carries not just a motion
|
|
command but also a fresh authoritative `Position` (the position+movement
|
|
combo case, or `MoveToObject`/`MoveToPosition` in `unpack_movement`'s cases
|
|
6/7), the code calls `CPhysicsObj::SetPositionInternal` (line 283892, addr
|
|
0x00515bd0) via the `MoveToManager`/`CPhysicsObj::MoveToObject`/
|
|
`SetScatterPositionInternal` machinery — this is a direct authoritative
|
|
`Position` set (through `AdjustPosition` + `CheckPositionInternal` +
|
|
`handle_all_collisions`), i.e. a hard reposition/snap when the server
|
|
sends a full position rather than only a motion-state delta. `unpack_movement`
|
|
case 0 (the plain `InterpretedMotionState`, used for ordinary walk<->run)
|
|
does NOT carry a `Position` at all — it only ever updates the motion
|
|
command/speed and lets local dead-reckoning (`CSequence`-driven `update` +
|
|
collision sweep, per Q5) carry the position forward until the next
|
|
authoritative position or motion packet arrives. There is no visible
|
|
"snap-if-error-exceeds-threshold" constant found in the traced functions
|
|
in this pass — the correction is structurally continuous
|
|
(`adjust_offset` blended every tick) rather than threshold-triggered,
|
|
based on what's directly observable in this file.
|
|
|
|
---
|
|
|
|
## Q7 — STOP: motion -> ready/stand
|
|
|
|
Stopping is **not special-cased outside the normal `GetObjectSequence`
|
|
machinery** — it is routed through the exact same link-transition logic as any
|
|
other substate change, targeting the style's registered idle/rest substate.
|
|
|
|
Entry points, both eventually reaching `CMotionTable::StopSequenceMotion`
|
|
(line 298954, addr 0x00522fc0):
|
|
|
|
```
|
|
CMotionInterp::StopInterpretedMotion(this, motion, params) line 305635, addr 0x00528470
|
|
-> if contact_allows_move fails OR standing_longjump-with-jump-motion:
|
|
just clears bookkeeping (InterpretedMotionState::RemoveMotion) and returns 0 — no
|
|
physical stop is even attempted (e.g. can't "stop turning" mid-air the same way)
|
|
-> else:
|
|
CPhysicsObj::StopInterpretedMotion(physics_obj, motion, params)
|
|
-> CPartArray::StopInterpretedMotion -> MotionTableManager::PerformMovement(type=StopCommand)
|
|
-> CMotionTable::StopObjectMotion(table, motion, speed, state, seq, outTicks)
|
|
-> CMotionTable::StopSequenceMotion(table, motion, speed, state, seq, outTicks)
|
|
if success: CMotionInterp::add_to_queue(this, ctx, READY_STANCE/*0x41000003*/, result)
|
|
InterpretedMotionState::RemoveMotion(&interpreted_state, motion) // clears forward_command
|
|
// back to 0x41000003 READY
|
|
```
|
|
|
|
`CMotionTable::StopSequenceMotion` (line 298954, addr 0x00522fc0):
|
|
```
|
|
int32 StopSequenceMotion(table, motion, speed, state, seq, outTicks) {
|
|
*outTicks = 0
|
|
if ((motion & 0x40000000) != 0 && motion == state->substate) {
|
|
// stopping the MAIN cycle (forward walk/run, not a modifier like
|
|
// sidestep): look up the style's default (idle/ready) substate and
|
|
// re-enter GetObjectSequence targeting IT — i.e. "stop" == "transition
|
|
// to idle", full link-anim machinery applies (Q2/Q4)
|
|
defaultSubstate = style_defaults[state->style]
|
|
return CMotionTable::GetObjectSequence(table, defaultSubstate, state, seq, 1.0f, outTicks, /*force*/1)
|
|
}
|
|
if ((motion & 0x20000000) != 0) {
|
|
// stopping a MODIFIER motion (e.g. sidestep, turn — layered on top of
|
|
// the base cycle rather than replacing it): find the modifier's
|
|
// MotionData and directly SUBTRACT its velocity/omega contribution
|
|
for (m in state->modifier_head-list) {
|
|
if (m.motion == motion) {
|
|
modData = modifiers.lookup((style<<16)|motion) ?? modifiers.lookup(motion)
|
|
if (modData != null) {
|
|
subtract_motion(seq, modData, m.speed_mod) // <-- direct velocity/omega
|
|
// subtraction, NO link anim,
|
|
// NO node changes — this IS
|
|
// how e.g. releasing sidestep
|
|
// while still running removes
|
|
// just the sideways component
|
|
MotionState::remove_modifier(state, m, prev)
|
|
return 1
|
|
}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
```
|
|
|
|
**Velocity zeroing:** happens in TWO places depending on stop type:
|
|
- Main-cycle stop (walk/run -> ready): via `GetObjectSequence`'s
|
|
link-transition branch, which unconditionally calls
|
|
`CSequence::clear_physics(sequence)` BEFORE appending the new link+cycle
|
|
chain — `clear_physics` (line 301194, addr 0x00524d50) zeroes
|
|
`sequence->velocity` and `sequence->omega` outright, then the new link
|
|
anim's OWN authored velocity/omega (if any) is added back via
|
|
`add_motion`. So there IS a hard zero, immediately followed by
|
|
re-population from the transition-to-idle clip's own baked
|
|
velocity/omega (typically ~0 for a stand/ready clip, hence "stop").
|
|
- Modifier stop (sidestep/turn release): `subtract_motion` directly removes
|
|
exactly that modifier's contribution (scaled by its `speed_mod`) from the
|
|
still-nonzero base-cycle velocity — no full zero, no `clear_physics` call,
|
|
because the base cycle (e.g. still running) keeps its own velocity intact.
|
|
|
|
**Stop/link animation:** YES — the idle-entry is itself an authored `get_link`
|
|
transition clip from the current substate to the style's default substate,
|
|
exactly like any other substate-to-substate transition (Q2/Q4). There is no
|
|
"instant freeze frame"; retail plays a deceleration/stop clip.
|
|
|
|
**Residual-sliding prevention:** because `clear_physics` zeroes
|
|
`sequence->velocity`/`omega` at the moment the stop-transition is initiated
|
|
(not merely when the stop ANIMATION finishes), the `apply_physics`/animation
|
|
per-frame delta stops contributing translation from THAT frame onward except
|
|
whatever the stop-link-clip's own authored motion data supplies via
|
|
`add_motion(seq, linkAnim, ...)` immediately after the clear. So there's no
|
|
"physics keeps sliding while the stop anim plays" bug window — the only motion
|
|
during the stop-link clip is whatever the clip's OWN keyframed velocity says
|
|
(typically small/decelerating by design), and once the link clip's frames are
|
|
exhausted and playback reaches the (typically near-static) idle cyclic node,
|
|
velocity is whatever that idle cycle's own `add_motion(..., cycleData, speed)`
|
|
contributed (near zero for a proper "Ready"/idle motion).
|
|
|
|
Additionally: `CPhysicsObj::RemoveLinkAnimations` (-> `CPartArray::HandleEnterWorld`
|
|
which is really "flush the motion table manager's queued link anims", called
|
|
from multiple guard points: whenever `physics_obj->cell == 0` inside both
|
|
`DoInterpretedMotion` and `StopInterpretedMotion`'s tail, from `HitGround`,
|
|
`LeaveGround`, and from `move_to_interpreted_state`'s caller context indirectly)
|
|
provides a hard-reset safety valve: if the object leaves the world/cell
|
|
mid-transition, any queued link-transition chain is discarded outright rather
|
|
than left dangling.
|
|
|
|
---
|
|
|
|
## Verbatim float constants collected in this pass
|
|
|
|
| Constant | Where | Meaning |
|
|
|---|---|---|
|
|
| `0.000199999995f` (~0.0002) | `change_cycle_speed` (298276), `CPhysicsObj::update_object` dt-epsilon (283950 area), `CPhysicsObj::SetTranslucency` (279489) | Generic "is this float effectively zero" epsilon used repeatedly across the physics/motion code — NOT walk/run-specific but the exact epsilon guarding the same-cycle speed-rescale divide-by-oldSpeed. |
|
|
| `2.0` (dt seconds) | `CPhysicsObj::update_object` (~284009) | Large-dt chunking threshold: any single `update_object` gap >= 2.0s is stepped in 1.0s `UpdateObjectInternal` slices to avoid one huge teleport-y integration step. |
|
|
| `1.0` (dt seconds) | same function | Per-slice step size used while chunking large dt. |
|
|
| `1.25f` | `CMotionInterp::get_state_velocity` (305160) | Sidestep speed multiplier when computing "logical state velocity" (`sidestep_speed * 1.25f`) — used for e.g. UI/AI queries, not the actual CSequence velocity. |
|
|
| `1.5f` | `CMotionInterp::apply_run_to_command` (305062), motion `0x6500000d` (TURN) case | Speed multiplier applied to turn commands. |
|
|
| `3f` / `-1f*3f` | `CMotionInterp::apply_run_to_command`, motion `0x6500000f` (SIDESTEP) case | Sidestep speed is clamped/scaled to exactly `+-3.0` depending on sign of the run-rate-scaled input (with sign preserved via the `x87_r7 < 0` branch). |
|
|
| `3.11999989f` (~3.12) | `CMotionInterp::get_state_velocity` (305176) | Walk-forward (`0x45000005`) logical-velocity multiplier. |
|
|
| `4f` | `CMotionInterp::get_state_velocity` (305180) | Run-forward (`0x44000007`) logical-velocity multiplier. |
|
|
| `96f` | `CPhysicsObj::update_object` (283974, player_distance gate) | Distance (world units, ~yards? — needs unit confirmation) beyond which a different `set_active` path is taken for a non-player-object relative to the player. |
|
|
| `0.100000001f` (0.1) | `CPhysicsObj::set_elasticity` (277817) | Elasticity clamp floor — unrelated to motion but shares the file region. |
|
|
|
|
---
|
|
|
|
## Function/line index (quick lookup for a synthesis pass)
|
|
|
|
| Symbol | Line | Addr | Role |
|
|
|---|---|---|---|
|
|
| `ACSmartBox::DispatchSmartBoxEvent` | 357117 | 0x005595d0 | Wire opcode switch (0xf619/0xf74c entry) |
|
|
| `CPhysics::SetObjectMovement` (stdcall) | 271370 | 0x00509690 | Sequence-number staleness gate, dispatch |
|
|
| `CPhysicsObj::unpack_movement` | 280179 | 0x00512040 | Lazily creates MovementManager, forwards |
|
|
| `MovementManager::unpack_movement` | 300563 | 0x00524440 | Deserializes wire struct, 10-way type switch |
|
|
| `MovementManager::move_to_interpreted_state` | 300259 | 0x00524170 | Lazy-create CMotionInterp, forward |
|
|
| `CMotionInterp::move_to_interpreted_state` | 305936 | 0x005289c0 | copy_movement_from + apply_current_movement + replay actions |
|
|
| `InterpretedMotionState::copy_movement_from` | 293301 | 0x0051e750 | Flat field overwrite (fwd/side/turn cmd+speed, style) |
|
|
| `CMotionInterp::apply_current_movement` | 305838 | 0x00528870 | Routes to raw (local) vs interpreted (remote) path |
|
|
| `CMotionInterp::apply_interpreted_movement` | 305713 | 0x00528600 | Issues DoInterpretedMotion per active command slot |
|
|
| `CMotionInterp::DoInterpretedMotion` | 305575 | 0x00528360 | contact_allows_move gate, dispatch to CPhysicsObj, queue |
|
|
| `CPhysicsObj::DoInterpretedMotion` | 276348 | 0x0050ea70 | Thin forward to CPartArray |
|
|
| `CPartArray::DoInterpretedMotion` | 286772 | 0x00518750 | Packs MovementStruct{type=2}, calls MotionTableManager |
|
|
| `MotionTableManager::PerformMovement` | 290906 | 0x0051c0b0 | type switch: DoObjectMotion / StopObjectMotion / StopObjectCompletely |
|
|
| `CMotionTable::DoObjectMotion` | 300045 | 0x00523e90 | -> GetObjectSequence(force=0) |
|
|
| `CMotionTable::GetObjectSequence` | 298636 | 0x00522860 | **THE cycle-swap/append/fast-path decision** |
|
|
| `same_sign` | 298253 | 0x00522260 | Direction-unchanged test |
|
|
| `change_cycle_speed` | 298276 | 0x00522290 | Same-cycle playback-rate rescale |
|
|
| `CMotionTable::get_link` | 298552 | 0x00522710 | Authored transition-anim lookup |
|
|
| `add_motion` / `combine_motion` / `subtract_motion` | 298437 / 298472 / 298492 | 0x005224b0 / 0x00522580 / 0x00522600 | Append CSequence nodes + scale velocity/omega in/out |
|
|
| `CSequence::append_animation` | 301777 | 0x00525510 | Node creation, tail-insert, first_cyclic bump |
|
|
| `CSequence::clear_physics` | 301194 (def not read in full but referenced) | 0x00524d50 | Zero velocity/omega |
|
|
| `CSequence::remove_cyclic_anims` | referenced 298701 etc | 0x00524e40 | Drop old cyclic tail before new transition |
|
|
| `CSequence::update` | 302402 | 0x00525b80 | update_internal (has anims) OR apply_physics (no anims) |
|
|
| `CSequence::update_internal` | 301839 | 0x005255d0 | Frame-advance (x87-obfuscated, not fully recoverable) |
|
|
| `CSequence::apply_physics` | 300955 | 0x00524ab0 | outDelta.origin += dt*velocity; rotate(dt*omega) |
|
|
| `CSequence::apricot` | 300978 | 0x00524b40 | Trim consumed nodes from head up to first_cyclic |
|
|
| `CPartArray::Update` | 285883 | 0x00517db0 | == CSequence::update |
|
|
| `CPhysicsObj::UpdatePositionInternal` | 280817 | 0x00512c30 | CPartArray::Update -> PositionManager::adjust_offset -> Frame::combine -> UpdatePhysicsInternal -> process_hooks |
|
|
| `CPhysicsObj::UpdateObjectInternal` | 283611 | 0x005156b0 | UpdatePositionInternal -> collision transition -> cached_velocity, per-tick UseTime calls |
|
|
| `CPhysicsObj::update_object` | 283950 | 0x00515d10 | Outer per-object driver, dt clamp/chunking |
|
|
| `CPhysicsObj::transition` | 280904 | 0x00512dc0 | Builds CTransition, sphere sweep, find_valid_position |
|
|
| `MotionTableManager::add_to_queue` | 290854 | 0x0051bfe0 | Append to pending_animations, prune redundant links |
|
|
| `MotionTableManager::remove_redundant_links` | 290771 | 0x0051bf20 | Collapse superseded queued link transitions |
|
|
| `MotionTableManager::CheckForCompletedMotions` | 290645 | 0x0051be00 | Per-tick poll: pop tickCount==0 entries, fire MotionDone |
|
|
| `MotionTableManager::AnimationDone` | 290558 | 0x0051bce0 | Anim-hook-driven pop via animation_counter |
|
|
| `CPhysicsObj::Hook_AnimDone` | 277845 | 0x0050fda0 | CAnimHook callback -> CPartArray::AnimationDone(1) |
|
|
| `CPhysicsObj::MotionDone` | 277856 | 0x0050fdb0 | Bridges MotionTableManager completion -> CMotionInterp queue |
|
|
| `MovementManager::MotionDone` | 300396 | 0x005242d0 | Forward |
|
|
| `CMotionInterp::MotionDone` | 305238 | 0x00527ec0 | Pops pending_motions head, clears stick/actions if flagged |
|
|
| `CMotionInterp::add_to_queue` | 305032 | 0x00527b80 | Appends to CMotionInterp::pending_motions |
|
|
| `CMotionInterp::motions_pending` | 305322 | 0x00527fe0 | pending_motions.head_ != null |
|
|
| `CMotionInterp::StopInterpretedMotion` | 305635 | 0x00528470 | Entry for stopping a command |
|
|
| `CMotionTable::StopObjectMotion` | 300053 | 0x00523ec0 | -> StopSequenceMotion |
|
|
| `CMotionTable::StopSequenceMotion` | 298954 | 0x00522fc0 | Main-cycle-stop (re-enter GetObjectSequence w/ default substate) vs modifier-stop (subtract_motion) |
|
|
| `CMotionTable::StopObjectCompletely` | 300062 | 0x00523ed0 | Iterates all modifiers + substate, stops each |
|
|
| `InterpretedMotionState::ApplyMotion` | 293531 | 0x0051ea40 | Bookkeeping-only overwrite of forward/sidestep/turn fields |
|
|
| `InterpretedMotionState::RemoveMotion` | 293315 | 0x0051e790 | Clears turn/sidestep/forward command back to defaults |
|
|
| `CPhysicsObj::RemoveLinkAnimations` | 277911 | 0x0050fe20 | -> CPartArray::HandleEnterWorld: flush queued link anims |
|
|
| `CMotionInterp::contact_allows_move` | 305471 | 0x00528240 | Gate: only creatures on solid ground can freely swap most motions |
|
|
| `CMotionInterp::PerformMovement` | 306221 | 0x00528e80 | LOCAL-input outer dispatcher; calls CheckForCompletedMotions synchronously (NOT used by remote/wire path) |
|
|
|
|
---
|
|
|
|
## Notes on scope / what was NOT fully resolved
|
|
|
|
- `CSequence::update_internal`'s exact per-frame arithmetic (how `frame_number`
|
|
advances, exact interpolation between `low_frame`/`high_frame`, and the
|
|
precise mechanism by which a per-frame authored position delta from
|
|
`AnimFrame`/`get_pos_frame` gets folded into the output `Frame*`) is
|
|
x87-obfuscated in this Binary Ninja pseudo-C dump — individual FPU
|
|
compare/branch sequences show as `/* unimplemented {fcomp ...} */` rather
|
|
than resolved C. This matches the documented project-wide limitation (see
|
|
`memory/feedback_bn_decomp_field_names.md` and the CLAUDE.md cdb-toolchain
|
|
section) that some floating-point-heavy retail functions don't fully
|
|
decompile via Binary Ninja and may need a cdb live-trace or manual
|
|
disassembly pass to pin exact behavior. What IS certain from the
|
|
surrounding code (append_animation/get_pos_frame/get_part_frame/apricot)
|
|
is the STRUCTURE: node-list advance + per-node authored frame data feeding
|
|
the output Frame.
|
|
- `PositionManager::adjust_offset`'s body (the Q6 soft-correction blend) was
|
|
not located inside this pseudo-C excerpt in this pass — only its call site
|
|
and sibling API surface (`UnStick`, `StopInterpolating`, `IsInterpolating`,
|
|
`IsFullyConstrained`, `GetStickyObjectID`) were confirmed. A follow-up grep
|
|
for `PositionManager::` method bodies (likely a different source file /
|
|
address range not covered by the anchors given) would be needed to get its
|
|
exact blend formula and any snap-threshold constant.
|
|
- No explicit "snap if error > threshold" constant was found for position
|
|
correction in the portions traced; the retail design as observed here is a
|
|
continuous per-tick blend (`adjust_offset`) plus occasional authoritative
|
|
hard `SetPositionInternal` when the wire message actually carries a
|
|
`Position` (MoveToObject/MoveToPosition/PositionAndMovement paths), not a
|
|
distance-threshold-triggered snap layered on top of ordinary motion-command
|
|
packets.
|