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