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>
36 KiB
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_statehas the high bit clear (i.e.>= 0, meaning the object is passive / dormant), it skips physics entirely and only ticksParticleManager+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 (viaset_frameorSetPositionInternal)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 byUpdatePhysicsInternal(acceleration integration)this->jumped_this_frame← clearedthis->contact_plane,this->sliding_normal,this->transient_statebits 1/4/8 ← updated bySetPositionInternal
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)). Thexhere is a stack zero vector compared against current origin — if origin is at world-zero it skips. Wait, re-reading: actuallyxwas overwritten on line 283641 to0f, then operator== between&x(stack zero) and&this->m_position.frame.m_fOrigin. This is checking "didUpdatePositionInternalmove us offm_position.frame.m_fOrigin"? No — it's comparing the local stack vectorx(zero) againstm_position. This is effectivelyis_zero(m_position)which is almost always false. Re-read more carefully: at line 283641float 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 commitsvar_40directly.
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:
- Animation root motion —
CPartArray::UpdatecallsCSequence::apply_physics, which writeslocalFrame.origin = dt * sequence.velocityand rotateslocalFramebydt * 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). - Position-manager offset —
PositionManager::adjust_offsetletsInterpolationManager,StickyManager,ConstraintManagermutate the local frame. - 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. - Physics integration on the composed frame —
UpdatePhysicsInternalEuler-integratesm_velocityVectorandm_omegaVectorinto 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
// 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);
...
}
// 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);
// 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 framem_velocityVector,m_omegaVector← updated inside UpdatePhysicsInternalm_position.frame← NOT TOUCHED here (commit happens inset_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)
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_velocityVectorstays at 0 for walking remotes — verify
Confirmed. For locomotion-driven remotes:
- Server sends
UpdateMotion(RunForward, speed=N). - Sequencer enters the RunForward cycle.
CSequence::apply_physicswritesdt * (0, 4*N, 0)to the local frame's origin every tick.Frame::combinerotates that body-local +Y into world space usingm_position.frame.m_fl2gvand adds to world pos.UpdatePhysicsInternalruns but withm_velocityVector ≈ 0(no physics push) → its Euler-translation step adds nothing meaningful. It still does the omega-rotate step usingm_omegaVectorif 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
// 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
- Translation step is gated — only runs when
velocity² > 0. For a walking remote withm_velocityVector == 0, this entire block is skipped. The animation's baked velocity (already inframe.originfromapply_physics+combine) is preserved. - Velocity step always runs — even when initial velocity was zero, gravity (
acceleration.z = PhysicsGlobals::gravitywhenstate[1] & 4, i.e.Gravity) accumulatesvelocity.z -= 9.8 * dtper tick. - Omega step always runs — uses
m_omegaVector(NOT sequencer omega). This is for knockback spin / scripted rotation; sequencer omega is consumed insideapply_physics. - Zero-velocity deadband at
|v| < 0.25applies 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 planeresult->collision_info.contact_plane_valid,contact_plane_is_water,contact_plane_cell_idresult->collision_info.sliding_normal,sliding_normal_validresult->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."
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← computedout.m_fl2gv← repopulated (viaset_rotatewhich callscache)
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
Frame::operator=(local_var_40, arg2)— copy- If
IsValid(local) == 0 && IsValidExceptForHeading(local) != 0→ reset rotation to identity (origin preserved). This protects against NaN quats from numerical drift. Frame::operator=(this->m_position.frame, local)— final assign- If NOT
(state[1] & 0x10)→CPartArray::SetFrame(part_array, &this->m_position.frame) 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:
InterpolationManager— smooths network-bursty position deltasStickyManager— locks position to a target object (e.g., aetheria attaches)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: callsupdate_internal(advance cursor through anim chain) ANDapricot(drop completed anims). Apricot does NOT call apply_physics. The local frame writes happen INSIDEupdate_internalvia its ownapply_physicscalls. - Else (no anims queued): calls
apply_physics(this, frame, dt, dt)directly with the currentsequence.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
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)
// 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
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
-
Where does omega come from for remotes? Retail's
m_omegaVectoris set byCMotionInterp::DoInterpretedMotionvia 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 ofObservedOmega(which is server-derived and lossy). -
Does CSequence::apply_physics always run, even on idle? Re-reading line 302413-302419: if
anim_list.head_ == 0ANDarg3 != 0, apply_physics runs with the currentsequence.velocity/sequence.omega. Idle cycles have zero baked velocity → apply_physics is a no-op. So idle remotes get no spurious motion. -
Frame.IsValid check in SetFrame: minor robustness item. Worth adding when porting set_frame.
-
Friction:
calc_frictiongets 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. -
PhysicsBody.update_object's MinQuantum gate: GameWindow.cs ~6633 has a long comment explaining why it bypasses
update_objectand callsUpdatePhysicsInternaldirectly. Retail'sUpdateObjectInternalis the entry; our top-level entry mirrors that. The 30 Hz quantum gate is in retail (seeMinQuantum=1/30s); retail addresses it by NOT subticking —UpdateObjectInternalis 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 halvedtper accumulated frame to match retail integration cadence.