# L.3 Per-Tick Body Update Pipeline — Retail Reference **Source**: `docs/research/named-retail/acclient_2013_pseudo_c.txt` (Sept 2013 EoR build, PDB-named, Binary Ninja pseudo-C). **Scope**: How retail advances a `CPhysicsObj` (player or remote) **one physics tick**, from the entry call through animation-driven motion, physics integration, and collision resolution. **Purpose**: Correct the env-var per-tick path in `GameWindow.cs` (~line 6428+) so it matches retail order-of-operations and behavior, especially for "walking remote" cases where `m_velocityVector` is supposed to stay zero. --- ## Top-level call graph ``` CPhysicsObj::UpdateObjectInternal(this, dt) @ 0x005156b0 └─ CPhysicsObj::UpdatePositionInternal(this, dt, &outFrame) @ 0x00512c30 │ ├─ CPartArray::Update(part_array, dt, &localFrame) @ 0x00517db0 │ │ └─ CSequence::update(&sequence, dt, &localFrame) @ 0x00525b80 │ │ ├─ CSequence::update_internal(...) // advances anim cursor │ │ └─ CSequence::apply_physics(this, frame, dt, dt) @ 0x00524ab0 │ │ ├─ frame.m_fOrigin += dt * sequence.velocity // ROOT MOTION │ │ └─ Frame::rotate(frame, dt * sequence.omega) // ROOT ROT │ ├─ PositionManager::adjust_offset(pos_mgr, &localFrame, dt) @ 0x00555190 │ │ ├─ InterpolationManager::adjust_offset │ │ ├─ StickyManager::adjust_offset │ │ └─ ConstraintManager::adjust_offset │ ├─ Frame::combine(outFrame, &m_position.frame, &localFrame) @ 0x005122e0 │ │ // outFrame = m_position.frame ⊗ localFrame (rotate-translate compose) │ ├─ CPhysicsObj::UpdatePhysicsInternal(this, dt, outFrame) @ 0x00510700 │ │ // Euler integration of m_velocityVector + m_accelerationVector + m_omegaVector │ │ // Modifies outFrame.m_fOrigin (translation) and rotation (Frame::grotate) │ │ // Modifies m_velocityVector (acceleration step) │ └─ CPhysicsObj::process_hooks(this) ├─ CPhysicsObj::transition(this, &m_position, &outFrame, 0) @ 0x00512dc0 │ └─ CTransition::find_valid_position(...) // BSP / sphere sweep ├─ CPhysicsObj::set_frame(this, &outFrame) @ 0x00514090 // (failure path) │ OR ├─ CPhysicsObj::SetPositionInternal(this, transition) @ 0x00515330 // (success path) └─ DetectionManager / TargetManager / MovementManager / CPartArray::HandleMovement / PositionManager::UseTime ``` --- ## 1. `CPhysicsObj::UpdateObjectInternal(this, dt)` — entry **Address**: `0x005156b0` (line 283611) **Signature**: `void __thiscall CPhysicsObj::UpdateObjectInternal(CPhysicsObj* this, float dt)` ### What it does (plain English) This is the **per-tick entry** for a physics object. It branches on `transient_state`: - If `transient_state` has the *high bit* clear (i.e. `>= 0`, meaning the object is *passive* / dormant), it skips physics entirely and only ticks `ParticleManager` + `ScriptManager`. - Otherwise it runs the **active path**: build a candidate next-frame, sweep collision, commit position, and tick all per-frame managers (Detection, Target, Movement, Position, parts). ### Order of operations (active path) | # | Line | What happens | |---|------|--------------| | 1 | 283631 | If `transient_state[1] & 1` → `set_ethereal(this, 0, 0)` | | 2 | 283634 | `this->jumped_this_frame = 0` | | 3 | 283635-283643 | Local stack `Frame var_40` initialized to identity (qw=1, origin=0). Note `var_48` holds `0x796910` = `Position::vtable`. | | 4 | 283644 | `Frame::cache(&var_40)` — recompute `m_fl2gv[0..8]` rotation matrix from quaternion | | 5 | 283646 | **`UpdatePositionInternal(this, dt, &var_40)`** — fills `var_40` with the candidate next-frame | | 6 | 283651-283655 | Check `part_array && CPartArray::GetNumSphere(part_array)` — does this object have a collision sphere? | | 7a | 283655 (no spheres or zero-translation): set `m_position.frame = var_40` directly via `set_frame`, zero `cached_velocity`, no transition. | | 7b | 283657 (has spheres AND moved): run `transition()` collision sweep | | 7b1 | 283661-283670 | If `state[1] & 1` (Hooks?), set heading from translation direction; else if `(state & ScaledVelocity) && !is_zero(m_velocityVector)`, set heading from velocity. | | 7b2 | 283673 | `transition(this, &m_position, &var_48, 0)` — sweeps sphere from current pos to candidate pos `var_48`. Note `var_48` here refers to the **Position** struct that has been built (the local var holds an embedded Position with vtable `0x796910` and frame `var_40`). | | 7b3 | 283675-283696 | If `transition == nullptr` (no valid path): keep current frame via `set_frame(&var_40)`, zero `cached_velocity`. Otherwise: compute `cached_velocity = (final_pos - current_pos) / dt`, then `SetPositionInternal(this, transition)`. | | 8 | 283733 | `DetectionManager::CheckDetection` | | 9 | 283738 | `TargetManager::HandleTargetting` | | 10 | 283743 | `MovementManager::UseTime` | | 11 | 283748 | `CPartArray::HandleMovement` | | 12 | 283753 | `PositionManager::UseTime` | | 13 | 283755 | `goto label_5159b8` → ticks `ParticleManager` + `ScriptManager`, then returns | ### Side effects - `this->m_position.frame` ← committed final pose (via `set_frame` or `SetPositionInternal`) - `this->cell` ← may change (cell crossing) - `this->cached_velocity` ← actual delta achieved this tick / dt (used elsewhere for collision profiles, etc.) - `this->m_velocityVector` ← updated by `UpdatePhysicsInternal` (acceleration integration) - `this->jumped_this_frame` ← cleared - `this->contact_plane`, `this->sliding_normal`, `this->transient_state` bits 1/4/8 ← updated by `SetPositionInternal` ### Key conditions - **Spheres-and-moved gate** at line 283655 + 283657: `transition()` is only called when the object has a sphere AND the candidate frame's origin moved (`!operator==(zero_vec, m_position.frame.m_fOrigin)`). The `x` here is a stack zero vector compared against current origin — if origin is at world-zero it skips. Wait, re-reading: actually `x` was overwritten on line 283641 to `0f`, then operator== between `&x` (stack zero) and `&this->m_position.frame.m_fOrigin`. This is checking "did `UpdatePositionInternal` move us off `m_position.frame.m_fOrigin`"? **No** — it's comparing the local stack vector `x` (zero) against `m_position`. This is effectively `is_zero(m_position)` which is almost always false. **Re-read more carefully:** at line 283641 `float x = 0f;` is just initializing 3 stack floats (Vector3 x/y/z). The compare is "is the player at world origin (0,0,0)?". That's a degenerate check. So the practical interpretation: spheres-with-collision is the gate, and the no-sphere path just commits `var_40` directly. --- ## 2. `CPhysicsObj::UpdatePositionInternal(this, dt, outFrame)` — build candidate frame **Address**: `0x00512c30` (line 280817) **Signature**: `void __thiscall CPhysicsObj::UpdatePositionInternal(CPhysicsObj* this, float dt, Frame* outFrame)` ### What it does (plain English) Builds the **candidate next-tick frame** in `outFrame`. Three contributions, in order: 1. **Animation root motion** — `CPartArray::Update` calls `CSequence::apply_physics`, which writes `localFrame.origin = dt * sequence.velocity` and rotates `localFrame` by `dt * sequence.omega`. This is the per-cycle baked velocity from the current MotionData (e.g., RunForward emits ~4 m/s on +Y in body local). 2. **Position-manager offset** — `PositionManager::adjust_offset` lets `InterpolationManager`, `StickyManager`, `ConstraintManager` mutate the local frame. 3. **Compose with current world pose** — `Frame::combine(outFrame, &this->m_position.frame, &localFrame)` rotates the local-frame translation into world space and adds it to current world pos; multiplies quaternions. 4. **Physics integration on the composed frame** — `UpdatePhysicsInternal` Euler-integrates `m_velocityVector` and `m_omegaVector` *into the same outFrame* (this is the only place generalized velocity is consumed — see §4). ### Order of operations | # | Line | What happens | |---|------|--------------| | 1 | 280820-280826 | Init local `var_40` = identity Frame, plus `var_c/var_8/var_4 = 0` (these are 3 floats that look like a `Vector3 localTranslation` slot). | | 2 | 280827 | `Frame::cache(&var_40)` — populate rotation matrix `m_fl2gv[0..8]` (will be re-cached after `apply_physics`). | | 3 | 280829 | If `(state[1] & 0x40) == 0` (i.e. `PartsArray::Frozen` flag NOT set): | | 3a | 280834 | `CPartArray::Update(part_array, dt, &var_40)` — anim root motion goes into `var_40` | | 3b | 280836-280848 | If `transient_state & 2` (Stuck/Active?), scale the captured `var_c/var_8/var_4` (looks like a velocity scratch vector) by `m_scale`; else zero them. **This branch's effect on `var_40` is unclear from the decomp — possibly dead/diagnostic code.** | | 4 | 280853-280857 | If `position_manager != nullptr` → `PositionManager::adjust_offset(pm, &var_40, dt)` | | 5 | 280860 | `Frame::combine(outFrame, &this->m_position.frame, &var_40)` — outFrame = world-pose ⊗ local-frame | | 6 | 280862 | If NOT `(state[1] & 0x40)` → `UpdatePhysicsInternal(this, dt, outFrame)` (Euler step on the composed frame) | | 7 | 280865 | `process_hooks(this)` — runs queued FP-hooks (scale fade, translucency fade, etc.) | ### Verbatim retail snippets ```c // Line 280827-280834 Frame::cache(&var_40); if ((this->state[1] & 0x40) == 0) { CPartArray* part_array = this->part_array; if (part_array != 0) CPartArray::Update(part_array, dt, &var_40); ... } ``` ```c // Line 280853-280860 — order-critical: adjust_offset BEFORE combine if (position_manager != 0) PositionManager::adjust_offset(position_manager, &var_40, dt); Frame::combine(outFrame, &this->m_position.frame, &var_40); ``` ```c // Line 280862-280865 — physics integration AFTER compose, hooks last if ((this->state[1] & 0x40) == 0) CPhysicsObj::UpdatePhysicsInternal(this, dt, outFrame); CPhysicsObj::process_hooks(this); ``` ### Side effects - `outFrame` ← fully populated candidate next-tick world frame - `m_velocityVector`, `m_omegaVector` ← updated inside UpdatePhysicsInternal - `m_position.frame` ← **NOT TOUCHED** here (commit happens in `set_frame`/`SetPositionInternal`) ### Critical observation: state.0x40 ("Frozen") double-gate The `Frozen` flag both: - Skips animation root motion (CPartArray::Update) - Skips physics integration (UpdatePhysicsInternal) But still runs `Frame::combine` against the (now-empty) localFrame. Net effect: a frozen object's outFrame == m_position.frame (no motion). --- ## 3. `CSequence::apply_physics(seq, frame, dt, dt)` — animation root motion source **Address**: `0x00524ab0` (line 300955) **Signature**: `void __thiscall CSequence::apply_physics(const CSequence* this, Frame* arg2, double arg3, double arg4)` ### What it does Writes the **animation's baked locomotion** into `frame`. This is the *only* code path that produces translation for "walking" remotes — `m_velocityVector` stays at zero for them and `UpdatePhysicsInternal`'s Euler-translation step is a no-op. ### Pseudocode (plain) ```c double scale = fabs(arg3); // dt magnitude if (arg4 < 0) scale = -scale; // sign from arg4 frame->origin.x += scale * sequence->velocity.x; frame->origin.y += scale * sequence->velocity.y; frame->origin.z += scale * sequence->velocity.z; Vector3 omegaScaled = scale * sequence->omega; Frame::rotate(frame, &omegaScaled); // local-frame quaternion rotate ``` `sequence->velocity` and `sequence->omega` are **updated as the AnimSequenceNodes advance** (CSequence::update_internal handles cursor advancement; apply_physics consumes the current cycle's baked velocity). This is the data driven via MotionData fields — e.g. RunForward MotionData has `Velocity = (0, 4.0, 0)` baked in, scaled by speed multiplier. ### Why this matters for L.3 > The spec says `m_velocityVector` stays at 0 for walking remotes — verify **Confirmed.** For locomotion-driven remotes: 1. Server sends `UpdateMotion(RunForward, speed=N)`. 2. Sequencer enters the RunForward cycle. 3. `CSequence::apply_physics` writes `dt * (0, 4*N, 0)` to the local frame's origin every tick. 4. `Frame::combine` rotates that body-local +Y into world space using `m_position.frame.m_fl2gv` and adds to world pos. 5. `UpdatePhysicsInternal` runs but with `m_velocityVector ≈ 0` (no physics push) → its Euler-translation step adds nothing meaningful. It still does the omega-rotate step using `m_omegaVector` if non-zero. This is why **acdream's `apply_current_movement` → `body.Velocity` → `UpdatePhysicsInternal` chain is the wrong shape for remotes.** Retail does NOT push locomotion through `m_velocityVector`. It pushes locomotion through the sequencer's baked-velocity → local frame → combine. --- ## 4. `CPhysicsObj::UpdatePhysicsInternal(this, dt, frame)` — physics integration **Address**: `0x00510700` (line 278460) **Signature**: `void __thiscall CPhysicsObj::UpdatePhysicsInternal(CPhysicsObj* this, float dt, Frame* frame)` ### What it does (plain English) Runs **Euler integration of `m_velocityVector` + `m_accelerationVector` + `m_omegaVector`** on the supplied `frame`. This is what advances physics-driven motion (gravity falls, jump arcs, knockbacks). Walking locomotion does NOT flow through here. ### Order of operations | # | Line | What happens | |---|------|--------------| | 1 | 278463-278467 | Compute `var_28 = velocity.x² + velocity.y² + velocity.z²` | | 2 | 278473 | If `var_28 > 0` (i.e. velocity is non-zero, the FP comparison machinery checks this): | | 2a | 278475-278487 | If `var_28 > 50²` (terminal speed cap), normalize velocity and scale to magnitude 50. | | 2b | 278490 | `calc_friction(this, dt, var_28)` — friction adjusts velocity | | 2c | 278491-278502 | If `var_28 < 0.0625 + 0.0002` (≈ `0.25²` threshold), zero velocity (deadband). | | 2d | 278505-278511 | **Euler translation**: `frame.origin += dt * velocity + 0.5 * dt² * acceleration` (per-axis) | | 3 | 278513-278518 | `else if (movement_manager == 0 && transient_state & 2)` — clear `0x80` from transient_state | | 4 | 278521-278523 | **Velocity step**: `velocity += dt * acceleration` | | 5 | 278524-278528 | **Omega rotation**: build `var_18..` = `dt * omegaVector`, then `Frame::grotate(frame, &dtOmega)` (global-frame rotate by axis-angle, sin/cos applied to half-angle, quat multiply) | ### Verbatim retail snippets ```c // Line 278505-278511 — translation update (per axis) float var_20 = (acceleration.y * 0.5f) * dt * dt; ... // similar for x, z frame->origin.x += (dt * velocity.x) + (acceleration.x * 0.5f * dt * dt); frame->origin.y += (dt * velocity.y) + var_20; frame->origin.z += (dt * velocity.z) + (acceleration.z * 0.5f * dt * dt); // Line 278521-278523 — velocity step (always runs, even when velocity was 0) velocity.x += dt * acceleration.x; velocity.y += dt * acceleration.y; velocity.z += dt * acceleration.z; // Line 278524-278528 — angular rotation (always runs) Vector3 dtOmega = dt * omegaVector; Frame::grotate(frame, &dtOmega); ``` ### Critical observations 1. **Translation step is gated** — only runs when `velocity² > 0`. For a walking remote with `m_velocityVector == 0`, this entire block is skipped. The animation's baked velocity (already in `frame.origin` from `apply_physics` + `combine`) is preserved. 2. **Velocity step always runs** — even when initial velocity was zero, gravity (`acceleration.z = PhysicsGlobals::gravity` when `state[1] & 4`, i.e. `Gravity`) accumulates `velocity.z -= 9.8 * dt` per tick. 3. **Omega step always runs** — uses `m_omegaVector` (NOT sequencer omega). This is for knockback spin / scripted rotation; sequencer omega is consumed inside `apply_physics`. 4. **Zero-velocity deadband** at `|v| < 0.25` applies AFTER friction. Friction-decayed velocity below 0.25 m/s snaps to 0. --- ## 5. `CPhysicsObj::transition(this, fromPos, toPos, flags)` — collision sweep **Address**: `0x00512dc0` (line 280904) **Signature**: `const CTransition* __thiscall CPhysicsObj::transition(CPhysicsObj* this, const Position* fromPos, const Position* toPos, int32_t flags)` ### What it does Allocates a `CTransition` (collision-sweep workspace), initializes it with the object's spheres + path (fromPos → toPos in cell), tunes "frames stationary fall" from `transient_state`, calls `find_valid_position`, cleans up the transition workspace, returns the resolved `CTransition*` if successful or `nullptr` if no valid position. ### Order of operations | # | Line | What happens | |---|------|--------------| | 1 | 280907 | `CTransition* result = CTransition::makeTransition()` | | 2 | 280911 | `init_object(result, this, get_object_info(this, result, flags))` — copies state flags into `CTransition::object_info` | | 3 | 280915-280936 | If has spheres → `init_sphere(result, count, spheres, scale)`; else → `init_sphere(result, 1, &dummy_sphere, 1.0)` | | 4 | 280939 | `init_path(result, this->cell, fromPos, toPos)` — sets up SpherePath | | 5 | 280940-280947 | Set `frames_stationary_fall` from transient_state high bits: `0x40→3`, `0x20→2`, `0x10→1` | | 6 | 280949 | `int valid = CTransition::find_valid_position(result)` — runs full sweep | | 7 | 280950 | `cleanupTransition(result)` — releases sphere copies / scratch state | | 8 | 280952-280953 | Return `result` if `valid != 0`, else `0` | ### Side effects on caller The returned `CTransition*` carries: - `result->sphere_path.curr_pos` — final resolved Position (cell + frame) - `result->sphere_path.curr_cell` — final cell (may differ from start, may be null = lost) - `result->collision_info.contact_plane` — current ground plane - `result->collision_info.contact_plane_valid`, `contact_plane_is_water`, `contact_plane_cell_id` - `result->collision_info.sliding_normal`, `sliding_normal_valid` - `result->cell_array` — list of cells visited during the sweep These all flow into `SetPositionInternal`. --- ## 6. `CPhysicsObj::SetPositionInternal(this, transition)` — commit **Address**: `0x00515330` (line 283399) **Signature**: `int32_t __thiscall CPhysicsObj::SetPositionInternal(CPhysicsObj* this, const CTransition* arg2)` ### What it does Commits the transition's resolved position back into the physics object: copies the final frame, updates cell membership, updates contact plane / sliding normal / transient_state walkable bits, recalculates acceleration based on the new ground state, and fires `handle_all_collisions` for any contact reports. ### Order of operations | # | Line | What happens | |---|------|--------------| | 1 | 283402-283403 | Capture `transient_state`, `curr_cell` | | 2 | 283405-283410 | If `curr_cell == nullptr` (lost): `prepare_to_leave_visibility` + `store_position(curr_pos)` + `GotoLostCell` + clear `transient_state & 0x80` | | 3 | 283414-283456 | If same cell: update `m_position.objcell_id` and child cell ids; else `change_cell(curr_cell)` | | 4 | 283458 | `set_frame(this, &curr_pos.frame)` — commit final frame to `m_position.frame` and propagate to part array | | 5 | 283459-283464 | Copy `contact_plane` (N + d) and `contact_plane_cell_id` | | 6 | 283465-283473 | If `contact_plane_valid` → `transient_state \|= 1 (Contact)`; else `&= ~1` | | 7 | 283474 | `calc_acceleration(this)` — reset to gravity or zero based on Contact + Gravity flags | | 8 | 283475-283483 | If `contact_plane_is_water` → `transient_state \|= 8`; else `&= ~8` | | 9 | 283485-283510 | If now Contact: branch on `contact_plane.N.z >= PhysicsGlobals::floor_z` → `set_on_walkable(true)` else `(false)`. Else (not on contact): clear `transient_state & 2` (Active) — call `MovementManager::LeaveGround` if was active — then `calc_acceleration` again. | | 10 | 283512-283523 | Copy `sliding_normal` + flag bit 4 (`SlidingNormal`) | | 11 | 283524 | `handle_all_collisions(this, &collision_info, oldContact, oldActive)` — fires Weenie collision callbacks | | 12 | 283526-283538 | If has cell + state has `0x10000` (HasPhysicsBSP) → `calc_cross_cells`; else → `remove_shadows_from_cells` + `add_shadows_to_cells(&cell_array)` | ### `floor_z` constant `PhysicsGlobals::floor_z` is the cosine of the steepest walkable angle. Standard retail value is around `0.66417414` (≈ cos 49°). The check at line 283501-283506 is "is this slope steep enough that we are NOT on walkable ground?" — if `contact_plane.N.z < floor_z`, the slope is too steep, set `OnWalkable = false`. --- ## 7. `Frame::combine(out, a, b)` — frame composition **Address**: `0x005122e0` (line 280355) **Signature**: `void Frame::combine(Frame* out, const Frame* a, const Frame* b)` ### What it does (plain English) Composes two frames: **out = a then b** in the sense of "apply a's transform, then add b in a's local space." ```c out.origin = a.origin + a.m_fl2gv * b.origin // rotate b's translation by a's matrix, add to a's origin out.quaternion = a.quaternion * b.quaternion // standard quat multiply ``` The matrix `m_fl2gv` is the local-to-global rotation matrix, populated by `Frame::cache` from the quaternion. `m_fl2gv[0..8]` is column-major (with `[0]/[3]/[6]` = row 0, etc., based on the index pattern at line 280358). ### Side effects - `out.m_fOrigin`, `out.qw/qx/qy/qz` ← computed - `out.m_fl2gv` ← repopulated (via `set_rotate` which calls `cache`) ### Important: `combine` reads `a.m_fl2gv` directly If `a.m_fl2gv` is stale (quaternion changed without `Frame::cache`), `combine` produces garbage translation. This is why `Frame::cache(&var_40)` is called explicitly at line 280827 in `UpdatePositionInternal` before any operation that reads the matrix. --- ## 8. `Frame::cache(this)` — quat → rotation matrix **Address**: `0x00534df0` (line 319353) **Signature**: `void __fastcall Frame::cache(Frame* this)` ### What it does Populates `m_fl2gv[0..8]` (a 3×3 rotation matrix) from `qw/qx/qy/qz`. Standard quat-to-matrix formula: ``` m_fl2gv[0] = 1 - 2*(qy² + qz²) m_fl2gv[3] = 2*(qx*qy - qw*qz) m_fl2gv[6] = 2*(qx*qz + qw*qy) m_fl2gv[1] = 2*(qx*qy + qw*qz) m_fl2gv[4] = 1 - 2*(qx² + qz²) m_fl2gv[7] = 2*(qy*qz - qw*qx) m_fl2gv[2] = 2*(qx*qz - qw*qy) m_fl2gv[5] = 2*(qy*qz + qw*qx) m_fl2gv[8] = 1 - 2*(qx² + qy²) ``` ### Why it matters Every time the quaternion changes (e.g., omega-rotate or `set_rotate`), the cached matrix MUST be refreshed before another `combine`/`localtoglobal` reads it. Retail's `Frame::set_rotate` calls `Frame::cache` internally; manual quaternion edits do not. --- ## 9. `CPhysicsObj::set_frame(this, frame)` — commit frame to object **Address**: `0x00514090` (line 282139) ### What it does Validates the frame, copies it to `this->m_position.frame`, propagates to `part_array` (unless `state[1] & 0x10` = `Particle` flag), and updates children. ### Order 1. `Frame::operator=(local_var_40, arg2)` — copy 2. If `IsValid(local) == 0 && IsValidExceptForHeading(local) != 0` → reset rotation to identity (origin preserved). This protects against NaN quats from numerical drift. 3. `Frame::operator=(this->m_position.frame, local)` — final assign 4. If NOT `(state[1] & 0x10)` → `CPartArray::SetFrame(part_array, &this->m_position.frame)` 5. `UpdateChildrenInternal(this)` — recurse to attached children ### Why a SEPARATE local copy? So the validity-fix can run before committing. If we wrote directly into `m_position.frame` and then noticed it's invalid, we'd have already corrupted the live state. --- ## 10. `PositionManager::adjust_offset(pm, frame, dt)` — three-manager pass **Address**: `0x00555190` (line 352090) ### What it does Calls `adjust_offset(frame, dt)` on each of three optional managers in order: 1. `InterpolationManager` — smooths network-bursty position deltas 2. `StickyManager` — locks position to a target object (e.g., aetheria attaches) 3. `ConstraintManager` — clamps within a region Each is null-checked. For most remotes, all three are null, so this is a no-op. --- ## 11. `CPartArray::Update(arr, dt, frame)` & `CSequence::update` **Addresses**: `0x00517db0` (CPartArray::Update, line 285883), `0x00525b80` (CSequence::update, line 302402) ### What they do `CPartArray::Update` is a thin wrapper that calls `CSequence::update(&this->sequence, dt, frame)`. `CSequence::update`: - If `anim_list.head_ != 0`: calls `update_internal` (advance cursor through anim chain) AND `apricot` (drop completed anims). **Apricot does NOT call apply_physics.** The local frame writes happen INSIDE `update_internal` via its own `apply_physics` calls. - Else (no anims queued): calls `apply_physics(this, frame, dt, dt)` directly with the current `sequence.velocity` / `sequence.omega`. This means: **even with no animation queued, the sequencer keeps emitting baked velocity** (the cycle's resting motion). This is how a stationary idle character could still tick velocity. ### What CPartArray::UpdateParts is NOT `CPartArray::UpdateParts(arr, frame)` at `0x005190f0` (line 287281) is a separate function that COMPOSES per-part frames from the current `AnimFrame.frame` array against the parent `frame` argument. It is called from a different path — `CPhysicsObj::UpdateChild` and `CPartArray::SetFrame` — not from the per-tick UpdateObjectInternal pipeline. --- ## 12. Compact pseudocode of the whole pipeline ```c void CPhysicsObj::UpdateObjectInternal(float dt) { if (transient_state high bit set /* dormant */) { ParticleManager::UpdateParticles(); ScriptManager::UpdateScripts(); return; } if (cell == 0) return; if (transient_state[1] & 1) set_ethereal(0, 0); jumped_this_frame = 0; Frame candidate = identity; Frame::cache(&candidate); UpdatePositionInternal(dt, &candidate); // After this: candidate = m_position.frame ⊗ animRoot ⊗ posMgrAdjust ⊗ physicsEuler if (has_collision_sphere) { if (state[1] & 1) Frame::set_vector_heading(&candidate, dir_of_translation); else if ((state & ScaledVel) && !is_zero(m_velocityVector)) Frame::set_heading(&candidate, get_heading(m_velocityVector)); Position toPos = { vtable=Position, frame=candidate, cell=current }; CTransition* t = transition(&m_position, &toPos, 0); if (t == null) { set_frame(&candidate); // fall back: commit unswept candidate cached_velocity = (0,0,0); } else { cached_velocity = (t.curr_pos - m_position) / dt; SetPositionInternal(t); // commits curr_pos, updates contact plane, walkable, etc. } } else { // No sphere — commit candidate directly. if (movement_manager == null && (transient_state & 2)) transient_state &= ~0x80; set_frame(&candidate); cached_velocity = (0,0,0); } DetectionManager::CheckDetection(); TargetManager::HandleTargetting(); MovementManager::UseTime(); CPartArray::HandleMovement(); PositionManager::UseTime(); ParticleManager::UpdateParticles(); ScriptManager::UpdateScripts(); } void CPhysicsObj::UpdatePositionInternal(float dt, Frame* outFrame) { Frame local = identity; Frame::cache(&local); if (!(state[1] & 0x40 /* Frozen */)) { if (part_array) CPartArray::Update(part_array, dt, &local); // → CSequence::apply_physics writes: // local.origin += dt * sequence.velocity // Frame::rotate(&local, dt * sequence.omega) } if (position_manager) PositionManager::adjust_offset(position_manager, &local, dt); Frame::combine(outFrame, &m_position.frame, &local); // outFrame.origin = m_position.origin + m_position.m_fl2gv * local.origin // outFrame.quat = m_position.quat * local.quat if (!(state[1] & 0x40)) UpdatePhysicsInternal(dt, outFrame); process_hooks(); } void CPhysicsObj::UpdatePhysicsInternal(float dt, Frame* frame) { float v2 = velocity.x² + velocity.y² + velocity.z²; if (v2 > 0) { if (v2 > 50²) { // terminal speed cap normalize(velocity); velocity *= 50; } calc_friction(dt, v2); if (v2 < 0.25² + ε) // deadband velocity = 0; // Euler position step frame.origin += dt * velocity + 0.5 * dt² * acceleration; } else if (movement_manager == null && (transient_state & 2)) { transient_state &= ~0x80; } velocity += dt * acceleration; // ALWAYS runs (gravity accumulation) Vector3 dtOmega = dt * omegaVector; Frame::grotate(frame, &dtOmega); // ALWAYS runs } ``` --- ## 13. Cross-reference: acdream `GameWindow.cs` ~6428+ ### What acdream does today (legacy / non-env-var path) ```csharp // Lines 6488-6650 (annotated): 1. Force OnWalkable + Contact + Active. 2. apply_current_movement → body.Velocity = sequencer.GetStateVelocity (rotated by Orientation). 3. body.Omega = 0. 4. If ObservedOmega non-zero: integrate Orientation manually (from server-derived rate). 5. body.calc_acceleration(). 6. body.UpdatePhysicsInternal(dt). ← Euler-step on body.Position 7. ResolveWithTransition(prePos, postPos) ← BSP/terrain sweep 8. Body.Position = resolveResult.Position. ``` ### Mismatches with retail | # | Retail | acdream | Impact | |---|--------|---------|--------| | 1 | Locomotion via **sequencer baked velocity** consumed by `CSequence::apply_physics` (writes to local frame) | Locomotion via **`body.Velocity` = `apply_current_movement` output** consumed by Euler step in `UpdatePhysicsInternal` | acdream double-counts: sequencer gets ticked AND body.Velocity is set. If both contribute, motion overshoots. If only the body path runs (sequencer Advance returns frames but doesn't drive translation), animation plays without baked-velocity contribution. | | 2 | Order: anim-root → posMgr-offset → **combine with world pose** → physics Euler **on composed frame** → collision sweep on composed frame | Order: apply_current_movement (sets velocity) → manual omega-integrate orientation → physics Euler on body (independent of frame composition) → collision sweep | Acceleration term `0.5 * dt² * a` is applied to body.Position, but in retail it's applied to the *post-combine* frame. For grounded remotes (`acceleration ≈ 0`) the difference is small; for jumping/falling remotes the half-accel term is in the wrong reference frame. | | 3 | `m_velocityVector` for walking remotes ≈ **0** | `body.Velocity` for walking remotes = world-rotated locomotion vector (e.g. ~4 m/s) | Direct port question: should `body.Velocity` be zero for grounded locomotion, with motion coming entirely from sequencer baked velocity? **Yes per retail.** This is the L.3 fix. | | 4 | `Frame::grotate` always runs in UpdatePhysicsInternal using `m_omegaVector` | `body.Omega = 0` to skip integration; manual quat rotate from `ObservedOmega` outside body | acdream's manual path is functionally equivalent provided `ObservedOmega` is the right rate; retail's path uses the body's own omega field. Replacing acdream's manual integration with `body.Omega = derived_rate` and letting `UpdatePhysicsInternal` do `grotate` would be more retail-faithful. | | 5 | Velocity-deadband at `\|v\| < 0.25` and terminal-cap at `\|v\| < 50` are **inside** UpdatePhysicsInternal | Not implemented (or implemented at higher level) | Minor; only matters for friction-decayed bodies and ragdoll-speed clamps. | | 6 | Translation step is **GATED on velocity² > 0** | acdream Euler-integrates unconditionally | Means a stationary remote with non-zero acceleration (gravity) gets a Z-step in acdream that retail would skip until velocity itself becomes non-zero (gravity makes velocity.z negative on the **next** velocity-update step). 1-tick lag in retail; immediate in acdream. | | 7 | `set_frame` validates the frame and resets-to-identity if quat is invalid | acdream commits without explicit IsValid check | NaN-quat protection missing; can't be triggered in normal play but a single packet-decode bug could stick a remote with a corrupted orientation forever. | | 8 | Anim-frame combine + posMgr-offset happen **before** physics integration | acdream skips the local-frame compose entirely; physics on body.Position is independent | This is THE structural mismatch driving L.3. | ### Why the L.2 PositionManager attempt regressed (per CLAUDE.md note) The note says: > the env-var path drops the per-tick collision sweep (ResolveWithTransition) that the default path retains, causing a visible "staircase" pattern when remotes run up/down slopes Retail does NOT drop ResolveWithTransition — `transition()` runs every tick after UpdatePositionInternal builds the candidate. The bug was integrating PositionManager but skipping `transition()`/`SetPositionInternal` in the env-var branch. Retail's structure makes both mandatory. ### The shape L.3 should produce ```csharp void TickRemote(rm, dt) { // 1. Build candidate frame in local space. Frame local = Frame.Identity; sequencer.ApplyPhysicsToFrame(ref local, dt); // ← retail apply_physics: writes baked velocity & omega positionMgr.AdjustOffset(ref local, dt); // null for normal remotes; placeholder for L.4 // 2. Compose with world pose. Frame candidate = Frame.Combine(rm.Body.WorldFrame, local); // 3. Physics integration on the composed frame (gravity, knockback, terminal cap). rm.Body.UpdatePhysicsInternal(dt, ref candidate); // mutates m_velocity, candidate.origin (only if v² > 0), candidate.quat (always) // 4. Collision sweep candidate → resolved. if (rm.Body.HasCollisionSphere && candidate.Origin != rm.Body.Position) { var transition = _physicsEngine.Transition(rm.Body.WorldPos, candidate, rm.CellId); if (transition == null) { rm.Body.SetFrame(candidate); // fall back to unswept candidate rm.CachedVelocity = Vector3.Zero; } else { rm.CachedVelocity = (transition.CurrPos - rm.Body.WorldPos) / dt; rm.Body.SetPositionFromTransition(transition); // commits frame, contact_plane, walkable, sliding_normal } } else { rm.Body.SetFrame(candidate); } // 5. Per-tick managers. rm.DetectionManager?.Check(); rm.TargetManager?.Tick(); rm.MovementManager?.UseTime(); rm.PartArray.HandleMovement(); rm.PositionManager?.UseTime(); rm.ParticleManager?.Update(); rm.ScriptManager?.Update(); } ``` The key insight: **for walking remotes, `m_velocityVector` stays zero, locomotion enters via the sequencer's `apply_physics` writing into `local`, and `Frame::combine` rotates that body-local vector into world space using the current orientation.** This matches CSequence::apply_physics's documented behavior and matches what we observe — animation cycles produce locomotion *because the cycle's MotionData has baked velocity*, not because the network hands us a velocity. --- ## 14. Open questions for L.3 implementation 1. **Where does omega come from for remotes?** Retail's `m_omegaVector` is set by `CMotionInterp::DoInterpretedMotion` via the cycle's MotionData (TurnLeft/TurnRight bake omega). Our local player path already has this; verify the remote path reads from the same source instead of `ObservedOmega` (which is server-derived and lossy). 2. **Does CSequence::apply_physics always run, even on idle?** Re-reading line 302413-302419: if `anim_list.head_ == 0` AND `arg3 != 0`, apply_physics runs with the *current* `sequence.velocity` / `sequence.omega`. Idle cycles have zero baked velocity → apply_physics is a no-op. So idle remotes get no spurious motion. 3. **Frame.IsValid check in SetFrame**: minor robustness item. Worth adding when porting set_frame. 4. **Friction**: `calc_friction` gets called inside UpdatePhysicsInternal whenever velocity² > 0. We don't currently port friction. For grounded velocity-driven motion (knockbacks) it matters; for animation-driven motion (m_velocityVector ≈ 0) it doesn't. 5. **PhysicsBody.update_object's MinQuantum gate**: GameWindow.cs ~6633 has a long comment explaining why it bypasses `update_object` and calls `UpdatePhysicsInternal` directly. Retail's `UpdateObjectInternal` is the entry; our top-level entry mirrors that. The 30 Hz quantum gate is in retail (see `MinQuantum=1/30s`); retail addresses it by NOT subticking — `UpdateObjectInternal` is called at the engine tick rate (~30 Hz for retail's render loop, see CLAUDE.md retail debugger notes). Our 60 Hz tick may want a sub-step gate or to halve `dt` per accumulated frame to match retail integration cadence.