acdream/docs/research/2026-05-04-l3-port/01-per-tick.md
Erik de129bc164 feat(motion): L.3 M1 — fresh InterpolationManager port + retail spec
Rewrites src/AcDream.Core/Physics/InterpolationManager.cs from the spec
in docs/research/2026-05-04-l3-port/04-interp-manager.md. Public API
preserved (Vector3-returning AdjustOffset, Enqueue, Clear, IsActive,
Count) so PositionManager + GameWindow callers continue to compile;
internals are full retail spec.

Bug fixes vs prior port (audit 04-interp-manager.md § 7):

  #1  progress_quantum accumulates dt (sum of frame deltas), not step
      magnitude. Retail line 353140; the prior port's `+= step` made
      the secondary stall ratio meaningless.

  #3  Far-branch Enqueue (dist > AutonomyBlipDistance = 100m) sets
      _failCount = StallFailCountThreshold + 1 = 4, so the next
      AdjustOffset call's post-stall check fires an immediate blip-to-
      tail snap. Retail line 352944. Prior port silently drifted
      toward far targets at catch-up speed instead of teleporting.

  #4  Secondary stall test ports the retail formula verbatim:
      cumulative / progress_quantum / dt < CREATURE_FAILED_INTERPOLATION_PERCENTAGE.
      Audit notes the units are 1/sec (likely Turbine bug or x87 FPU
      misread by Binary Ninja) — mirrored byte-for-byte regardless.

  #5  Tail-prune is a tail-walking loop, not a single-tail compare.
      Multiple consecutive stale tail entries within DesiredDistance
      (0.05 m) of the new target collapse together. Retail line 352977.

  #6  Cap-eviction at the HEAD when count reaches 20 (already correct
      in the prior port; verified).

New API: Enqueue gains an optional `currentBodyPosition` parameter so
the far-branch detection can reference the body when the queue is
empty. Backward-compatible (default null = pre-far-branch behavior).

UseTime collapsed into AdjustOffset's tail (post-stall blip check)
since acdream has no per-tick UseTime call separate from
adjust_offset; identical semantic outcome.

State fields renamed to retail names with sentinel values:
  _frameCounter, _progressQuantum, _originalDistance (init = 999999f
  sentinel per retail line 0x00555D30 ctor), _failCount.

Tests:
- 17/17 InterpolationManagerTests green.
- New test Enqueue_FarBranch_PrearmsImmediateBlipOnNextAdjustOffset
  pins the bug #3 fix: enqueueing 150 m away triggers a same-tick
  blip (delta length ≈ 150 m), and the queue clears.

Spec tree: 17 research docs (00–14) under docs/research/2026-05-04-l3-port/.
00-master-plan + 00-port-plan describe the 8-phase rollout. 01-per-tick,
03-up-routing, 04-interp-manager, 05-position-manager-and-partarray,
06-acdream-audit, 14-local-player-audit are the L.3 spec used by this
commit and the M2 follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 14:56:42 +02:00

613 lines
36 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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