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

36 KiB
Raw Permalink Blame History

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] & 1set_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 motionCPartArray::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 offsetPositionManager::adjust_offset lets InterpolationManager, StickyManager, ConstraintManager mutate the local frame.
  3. Compose with current world poseFrame::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 frameUpdatePhysicsInternal 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 != nullptrPositionManager::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 frame
  • m_velocityVector, m_omegaVector ← updated inside UpdatePhysicsInternal
  • m_position.frameNOT 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)

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_movementbody.VelocityUpdatePhysicsInternal 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

  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_validtransient_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_watertransient_state |= 8; else &= ~8
9 283485-283510 If now Contact: branch on contact_plane.N.z >= PhysicsGlobals::floor_zset_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 ← 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

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

  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.