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>
613 lines
36 KiB
Markdown
613 lines
36 KiB
Markdown
# 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.
|