acdream/docs/research/2026-05-04-l3-port/08-update-object-substep-and-frame.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

40 KiB

L.3 port — update_object substepping + Frame operations

Source: docs/research/named-retail/acclient_2013_pseudo_c.txt (Sept 2013 EoR PDB-named decompilation, BinaryNinja pseudo-C). Cross-checked against ACE's port (references/ACE/Source/ACE.Server/Physics/PhysicsObj.cs, PhysicsGlobals.cs).

This document extracts the per-tick variable-dt substepping algorithm and the Frame composition primitives that drive the per-frame physics integration. It answers:

  • What is the substepping algorithm? (HugeQuantum discard → MaxQuantum slicer loop → MinQuantum remainder)
  • What happens for very small dt? (early-return at EPSILON, NOT MinQuantum — retail processes every frame)
  • How is LastUpdateTime advanced? (always to PhysicsTimer::curr_time after the loop)
  • What does process_hooks do? (iterates linked-list of PhysicsObjHooks + anim_hooks per frame, executing & removing finished ones)
  • Frame::combine semantics: out = a · b — Frame transform composition (rotate b's origin by a's basis, then add a's origin; quaternion product a.q * b.q for orientation).

The constants MinQuantum/MaxQuantum/HugeQuantum are not directly visible in the BinaryNinja decompiled update_object because the BN decompiler corrupted some immediate floats to 0.0. The constants are recovered cleanly from ACE's PhysicsGlobals.cs, which itself is a faithful port of the same retail binary. The 0.000199999995f (= EPSILON = 0.0002) constant IS visible in the decomp (line 283996) — it's the early-exit tolerance distinct from MinQuantum.


1 — CPhysicsObj::update_object (FUN_00515D10) — main per-frame entry

Signature: void __fastcall CPhysicsObj::update_object(CPhysicsObj* this) Source: acclient_2013_pseudo_c.txt:283950-284055

Verbatim relevant pseudo-C (lines 283950-284055)

00515d10  void __fastcall CPhysicsObj::update_object(class CPhysicsObj* this) {

  // Bail-out 1: parented (held by another obj), no cell, or hidden (state & 0x1000000)
  if (this->parent != 0 || this->cell == 0 || (this->state & 0x1000000) != 0) {
      this->transient_state &= 0xffffff7f;     // clear "active" flag
      return;
  }

  // Player-distance update: if a player object exists, compute offset and
  // toggle "active" transient flag based on 96.0f distance gate.
  CPhysicsObj* player = CPhysicsObj::player_object;
  if (player != 0) {
      Vector3 offset;
      Position::get_offset(&player->m_position, &offset, &this->m_position);
      this->player_vector = offset;
      this->player_distance = sqrtf(offset.x*offset.x + offset.y*offset.y + offset.z*offset.z);
      // [actually plain |offset.x| in BN noise; ACE uses .Length()]
      if (this->player_distance >= 96.0f) {
          // beyond the active radius; deactivate
          this = CPhysicsObj::obj_maint;       // this overwritten — BN noise
      }
      if (this->player_distance >= 96.0f || this->part_array == 0) {
          this = this_3;
          CPhysicsObj::set_active(this, 1);
      } else {
          this_3->transient_state &= 0xffffff7f;   // clear active
      }
  }

  // ── dt computation ────────────────────────────────────────────────────────
  double dt = Timer::cur_time - this_3->update_time;
  PhysicsTimer::curr_time = this_3->update_time;       // seed phys clock with this obj's last-update

  // ── Guard 1: dt < EPSILON (0.000199999995f ≈ 0.0002 s) ────────────────────
  // Retail tolerance for "essentially zero" — NOT MinQuantum.
  // If dt < EPSILON, bump update_time and return without any simulation.
  if (dt < 0.000199999995f) {        // line 283996
      this_3->update_time = Timer::cur_time;
      return;
  }

  // ── Guard 2: dt > HugeQuantum (2.0 s) — discard stale dt ─────────────────
  // (Constant 2.0 visible at line 284009 — "long double temp1 = 2.0;")
  if (dt > 2.0) {
      this_3->update_time = Timer::cur_time;
      return;
  }

  // ── Substep loop: while dt > MaxQuantum, slice off MaxQuantum chunks ─────
  // BN corrupted MaxQuantum to "0.0" in the loop body, but the loop structure
  // is unmistakable (line 284031 do-while). ACE's port confirms MaxQuantum=0.1.
  if (dt > 0.0f /* MaxQuantum=0.1 */) {
      do {
          PhysicsTimer::curr_time += /* MaxQuantum */ 0.1;
          CPhysicsObj::UpdateObjectInternal(this_3, /* MaxQuantum */ 0.1f);
          dt -= /* MaxQuantum */ 0.1;
      } while (dt > /* MaxQuantum */ 0.1);
  }

  // ── Final remainder: if dt > MinQuantum (1/30), simulate the leftover ────
  // BN: line 284046 "if (!(p_1) || ... > 0.0) { ... UpdateObjectInternal(remainder)
  // }" — the comparison constant should be MinQuantum=1/30=0.0333f per ACE.
  if (dt > /* MinQuantum */ 0.0333f) {
      PhysicsTimer::curr_time += dt;
      CPhysicsObj::UpdateObjectInternal(this_3, (float)dt);
  }

  // Advance update_time to the consumed phys clock time.
  this_3->update_time = PhysicsTimer::curr_time;
}

Constants — recovered values

From references/ACE/Source/ACE.Server/Physics/PhysicsGlobals.cs:9-43:

Symbol Hex (float32) Value Meaning
EPSILON 0x3949A18A 0.000199999995f ≈ 0.0002 s "essentially zero" tolerance (visible in retail decomp line 283996)
MinQuantum 0x3D088889 1.0f / 30.0f ≈ 0.03333 s (30 fps) minimum simulation step
MaxQuantum 0x3DCCCCCD 0.1f (10 fps) substep cap — BN-corrupted to 0.0 in pseudo-C but confirmed via ACE
HugeQuantum 0x40000000 2.0f (0.5 fps) upper bound — beyond this, dt is discarded as stale (visible line 284009)

Note on the BN-decomp corruption: lines 284034, 284036-284037, 284049 in the pseudo-C show ((long double)0.0) where retail clearly reads non-zero immediates from .rdata. The decompiler dropped the immediate during constant-folding when sourcing from a global. Cross-reference with ACE confirms these are MaxQuantum=0.1 in the loop body and 0.0333 in the final-remainder guard.

update_object_server — does it exist?

No. Search of acclient_2013_pseudo_c.txt for update_object_server returns zero hits. There is only CPhysicsObj::update_object (the per-frame driver) and CPhysicsObj::UpdateObjectInternal (the per-substep worker). ACE introduced server-side variants that don't exist in retail.


2 — CPhysicsObj::UpdateObjectInternal (FUN_005156B0) — per-substep worker

Signature: void __thiscall CPhysicsObj::UpdateObjectInternal(CPhysicsObj* this, float arg2) Source: acclient_2013_pseudo_c.txt:283611-283757

This is the function called once per substep with arg2 = dt (≤ MaxQuantum). Two main branches based on transient_state sign bit (which is Active, 0x80):

005156b0  void UpdateObjectInternal(CPhysicsObj* this, float arg2) {

  // Branch A: obj is INACTIVE (transient_state >= 0, i.e. high bit clear).
  // Just tick particles + scripts; no movement.
  if ((int16_t)this->transient_state >= 0) goto label_5159b8;

  // Branch B: obj is ACTIVE.
  if (this->cell == 0) return;

  // ── Active-mover path ───────────────────────────────────────────────────
  if ((this->transient_state & 0x100) != 0)            // line 283631 — clears Sticky
      CPhysicsObj::set_ethereal(this, 0, 0);
  this->jumped_this_frame = 0;

  // Build a local Frame (stack-allocated, identity quaternion).
  Position offsetPos = { objcell_id=0x796910, qw=1, qx=0,qy=0,qz=0,
                         origin={0,0,0} };
  Frame offsetFrame;                                    // stack
  Frame::cache(&offsetFrame);                           // line 283644

  uint32_t cellId = this->m_position.objcell_id;

  // ── 1) UpdatePositionInternal: integrates velocity/accel into offsetFrame
  st0_1 = CPhysicsObj::UpdatePositionInternal(this, arg2, &offsetFrame);
  // line 283646

  CPartArray* parts = this->part_array;
  uint32_t numSpheres = parts ? CPartArray::GetNumSphere(parts) : 0;

  if (parts != 0 && numSpheres != 0) {
      if (Vector3::operator==(&offsetFrame.origin, &this->m_position.frame.origin) == 0) {
          // origin moved — need a transition (collision sweep)
          uint32_t state = this->state;
          if ((state & 0x100) != 0) {                   // line 283661
              // facing-velocity heading mode
              Vector3 dir;
              AC1Legacy::Vector3::operator-(&offsetFrame.origin, &dir,
                                            &this->m_position.frame.origin);
              Vector3::Normalize(&dir);
              Frame::set_vector_heading(&offsetFrame, &dir);
          }
          else if ((state & "activation type (%s) with '%s' b…" /* a high state-bit */) != 0
                   && AC1Legacy::Vector3::is_zero(&this->m_velocityVector) == 0) {
              float heading = AC1Legacy::Vector3::get_heading(&this->m_velocityVector);
              Frame::set_heading(&offsetFrame, heading);
          }

          // ── COLLISION SWEEP — port of FUN_005148A0 / Transition::FindTransitional… ──
          CTransition* tx = CPhysicsObj::transition(this, &this->m_position,
                                                    &offsetPos /* desired */,
                                                    /*flags*/ 0);

          if (tx == 0) {
              // sweep failed — keep current position, snap to offsetFrame, zero velocity
              CPhysicsObj::set_frame(this, &offsetFrame);
              this->cached_velocity = {0,0,0};
          } else {
              // sweep succeeded — measured velocity = (curr_pos - new_pos)/dt
              Vector3 deltaPos;
              Position::get_offset(&this->m_position, &deltaPos, &tx->sphere_path.curr_pos);
              Vector3 measuredVel;
              Vector3::operator/(&deltaPos, &measuredVel, arg2);
              this->cached_velocity = measuredVel;
              CPhysicsObj::SetPositionInternal(this, tx);    // commits new pos+cell
          }
      } else {
          // origin didn't move — just set frame and clear velocity
          CPhysicsObj::set_frame(this, &offsetFrame);
          this->cached_velocity = {0,0,0};
      }
  } else {
      // No part_array or no spheres — clear "stationary fall" flag if free, set frame, clear velocity
      if (this->movement_manager == 0) {
          uint32_t ts = this->transient_state;
          if ((ts & 2) != 0) this->transient_state = ts & 0xffffff7f;
      }
      CPhysicsObj::set_frame(this, &offsetFrame);
      this->cached_velocity = {0,0,0};
  }

  // ── 2) Per-frame ticks (managers + parts + position interp) ─────────────
  if (this->detection_manager != 0)
      DetectionManager::CheckDetection(this->detection_manager);
  if (this->target_manager != 0)
      TargetManager::HandleTargetting(this->target_manager);
  if (this->movement_manager != 0)
      MovementManager::UseTime(this->movement_manager);   // animation tick
  if (this->part_array != 0)
      CPartArray::HandleMovement(this->part_array);
  if (this->position_manager != 0)
      PositionManager::UseTime(this->position_manager);

label_5159b8:
  // ── 3) Particles + scripts (always, both Active and Inactive branches) ──
  if (this->particle_manager != 0)
      ParticleManager::UpdateParticles(this->particle_manager);
  if (this->script_manager != 0)
      ScriptManager::UpdateScripts(this->script_manager);
}

Key sequencing per substep:

  1. Build identity local Frame (offsetFrame).
  2. UpdatePositionInternal(this, dt, &offsetFrame) — integrate motion into the frame.
  3. If origin changed and we have collidable spheres → run transition() (collision sweep).
  4. If sweep succeeds → commit via SetPositionInternal; cached_velocity = (deltaPos / dt).
  5. If sweep fails → snap to offsetFrame directly; zero velocity.
  6. Tick managers (Detection, Target, Movement, PositionManager).
  7. Tick CPartArray::HandleMovement (per-part frame propagation).
  8. Tick particle_manager + script_manager.

process_hooks is NOT called here — it lives inside UpdatePositionInternal (see §3).


3 — CPhysicsObj::UpdatePositionInternal (FUN_00512C30)

Signature: void UpdatePositionInternal(CPhysicsObj* this, float dt, Frame* outFrame) Source: acclient_2013_pseudo_c.txt:280817-280866

00512c30  void UpdatePositionInternal(CPhysicsObj* this, float dt, Frame* outFrame) {

  // ── Step A: tabula-rasa local frame, zero translation ───────────────────
  Frame localFrame;
  localFrame.qw=1, localFrame.qx=0,qy=0,qz=0;
  localFrame.origin = {0,0,0};
  Frame::cache(&localFrame);     // build l2gv basis from quat

  // ── Step B: animation drives a delta-frame into localFrame ──────────────
  // (Skipped if state & 0x4000 / "static decoration" bit.)
  if ((this->state & 0x4000) == 0) {
      if (this->part_array != 0) {
          // CPartArray::Update walks the AnimSequencer, applies animFrame deltas,
          // adds them onto var_c/var_8/var_4 (the local origin). It also pulls
          // OmegaVector and applies it into the quaternion. After this returns,
          // localFrame.origin holds the local-frame velocity*dt + omega-rotation.
          CPartArray::Update(this->part_array, dt, &localFrame);
      }

      // Scale by m_scale if Sticky flag is set (riding a moving platform).
      // Otherwise zero the local-origin (just keep rotation).
      if ((this->transient_state & 2) /* HasContact */ == 0) {
          localFrame.origin *= 0.0f;       // zero translation
      } else {
          localFrame.origin *= this->m_scale;
      }
  }

  // ── Step C: apply position_manager interpolation offset (smooth catch-up)
  if (this->position_manager != 0)
      PositionManager::adjust_offset(this->position_manager, &localFrame, dt);

  // ── Step D: COMBINE — outFrame = m_position.frame * localFrame ──────────
  // This rotates localFrame.origin by m_position.frame's basis, adds m_position.frame's
  // origin, multiplies the quaternions: outFrame.q = m_position.q * localFrame.q.
  Frame::combine(outFrame, &this->m_position.frame, &localFrame);  // line 280860

  // ── Step E: if not "static decoration", run physics (gravity, friction…)
  if ((this->state & 0x4000) == 0)
      CPhysicsObj::UpdatePhysicsInternal(this, dt, outFrame);

  // ── Step F: dispatch hooks (per-frame scripted callbacks + anim hooks) ──
  CPhysicsObj::process_hooks(this);                     // line 280865
}

This is the function that produces the desired post-tick world frame in outFrame. The caller (UpdateObjectInternal) then routes that through the collision sweep.


4 — CPhysicsObj::process_hooks (FUN_00511550)

Signature: void __fastcall CPhysicsObj::process_hooks(CPhysicsObj* this) Source: acclient_2013_pseudo_c.txt:279431-279486

00511550  void process_hooks(CPhysicsObj* this) {

  // ── Linked-list hooks (vtable->Execute) ─────────────────────────────────
  // PhysicsObjHook is a polymorphic interface (translucency-fade, scale-fade,
  // visibility-fade, FPHook, etc). When Execute returns nonzero, the hook is
  // "done" — unlink and delete it.
  PhysicsObjHook* h = this->hooks;
  while (h != 0) {
      PhysicsObjHook* next = h->next;
      if (h->vtable->Execute(this) != 0) {
          // unlink h from doubly-linked list
          if (h->next != 0) h->next->prev = h->prev;
          if (h->prev == 0) this->hooks = h->next;
          else              h->prev->next = h->next;
          h->prev = h->next = 0;
          h->vtable = (vtable_t*)0x7c6b20;     // PhysicsObjHook base vtable
          operator delete(h);
      }
      h = next;
  }

  // ── Anim hooks (one-shot bag, executed and cleared every frame) ─────────
  uint32_t n = this->anim_hooks.m_num;
  if (n > 0) {
      for (uint32_t i = 0; i < this->anim_hooks.m_num; i++)
          this->anim_hooks.m_data[i]->vtable->Execute(this);
      AC1Legacy::SmartArray<CAnimHook*>::shrink(&this->anim_hooks);
      this->anim_hooks.m_num = 0;
  }
}

What it does:

  • hooks (linked list): persistent-until-done callbacks (translucency lerp, scale lerp, FPHook for fade events, etc). Each Execute returns done=1 → delete.
  • anim_hooks (SmartArray<CAnimHook*>): one-shot per-frame anim events (sound triggers, particle spawns, attack-frame markers fired by AnimationSequencer). Always cleared every frame.

This is invoked once per substep at the END of UpdatePositionInternal. acdream's port has separate routers (AnimationHookRouter, AnimationCommandRouter) but no equivalent of the persistent PhysicsObjHook linked list yet.


5 — CPhysicsObj::calc_acceleration (FUN_00510950)

Signature: void __fastcall CPhysicsObj::calc_acceleration(CPhysicsObj* this) Source: acclient_2013_pseudo_c.txt:278533-278560

00510950  void calc_acceleration(CPhysicsObj* this) {
  uint8_t ts = (int8_t)this->transient_state;

  // Special case: Active + HasContact + state-bit-?? → freeze (zero accel + omega).
  // Used for "standing still on a surface" steady-state.
  if ((ts & 1) != 0 && (ts & 2) != 0 && (this->state & 0x100 /* state's high mask */) == 0) {
      this->m_accelerationVector = {0, 0, 0};
      this->m_omegaVector        = {0, 0, 0};
      return;
  }

  // Gravity gate: state bit 0x4 (= GravityFlag).
  if ((this->state & 0x400 /* gravity bit, 0x4 << 8 in BN ushort masking */) == 0) {
      // Gravity OFF — zero acceleration (note: omega NOT zeroed)
      this->m_accelerationVector = {0, 0, 0};
      return;
  }

  // Default: gravity ON → vertical acceleration = PhysicsGlobals::gravity
  this->m_accelerationVector = {0, 0, PhysicsGlobals::gravity};   // gravity ≈ -9.8
}

Per-frame called by UpdatePhysicsInternal (which is called from UpdatePositionInternal step E above). acdream's PhysicsBody.calc_acceleration matches this contract.


6 — CPhysicsObj::transition (FUN_00512DC0)

Signature: CTransition const* transition(CPhysicsObj* this, Position const* fromPos, Position const* toPos, int32_t flags) Source: acclient_2013_pseudo_c.txt:280904-280957

00512dc0  CTransition* transition(CPhysicsObj* this, Position* from, Position* to, int32_t flags) {
  CTransition* tx = CTransition::makeTransition();
  if (tx == 0) return 0;

  // Init the object info struct (collidesWith, isMissile, etc) using flags arg
  CTransition::init_object(tx, this, CPhysicsObj::get_object_info(this, tx, flags));

  // Init sphere(s) to sweep — typically 1 humanoid sphere or N for parts
  CPartArray* parts = this->part_array;
  uint32_t n = parts ? CPartArray::GetNumSphere(parts) : 0;
  if (parts == 0 || n == 0) {
      CTransition::init_sphere(tx, 1, &dummy_sphere, 1.0f);
  } else {
      float scale = this->m_scale;
      CSphere* spheres = CPartArray::GetSphere(parts);
      uint32_t nSph = CPartArray::GetNumSphere(parts);
      CTransition::init_sphere(tx, nSph, spheres, scale);
  }

  // Path: from → to in cell `this->cell`
  CTransition::init_path(tx, this->cell, from, to);

  // Stationary-fall mask: tighter checks based on transient_state ContactPlane bits
  uint8_t ts = (int8_t)this->transient_state;
  if ((ts & 0x40) != 0)        tx->collision_info.frames_stationary_fall = 3;
  else if ((ts & 0x20) != 0)   tx->collision_info.frames_stationary_fall = 2;
  else if ((ts & 0x10) != 0)   tx->collision_info.frames_stationary_fall = 1;

  // Run the actual sweep — returns nonzero on success
  int32_t ok = CTransition::find_valid_position(tx);

  // NOTE: BN shows cleanupTransition(tx) BEFORE the success check — this
  // looks wrong but BN's stack-frame analysis is unreliable here. ACE's
  // port (PhysicsObj.transition) calls cleanup AFTER, conditionally.
  CTransition::cleanupTransition(tx);
  return (ok != 0) ? tx : 0;
}

find_valid_position is just an alias that calls find_transitional_position (line 273898). The actual sweep loop is CTransition::find_transitional_position (FUN_0050BDF0) at line 273613, which:

  1. Computes step count (calc_num_steps) — dt-derived based on offset length and sphere radius.
  2. Loops, advancing the sphere along the offset, calling transitional_insert each step.
  3. Each step: cell list → BSP collision → step-up / edge-slide / contact-plane logic.

This is the heart of the "collision sweep" the env-var path currently bypasses.


7 — CPhysicsObj::SetPositionInternal overloads

Two overloads exist. The "post-sweep commit" form is FUN_00515BD0, called with (this, ebp /*tx*/):

00515bd0  SetPositionError SetPositionInternal(CPhysicsObj* this, Position* pos,
                                                SetPositionStruct* sps, CTransition* tx) {
  CSphere* localSph = tx->sphere_path.local_sphere;
  if (this->cell == 0) CPhysicsObj::prepare_to_enter_world(this);

  int32_t ecx_2 = (sps->flags >> 5) & 1;       // "AdjustPosition" flag
  CTransition* outTx = nullptr;
  CPhysicsObj::AdjustPosition(pos, localSph, &outTx, ecx_2, 1);

  if (outTx == 0) {
      // Off the map — go to "lost cell"
      CPhysicsObj::prepare_to_leave_visibility(this);
      CPhysicsObj::store_position(this, pos);
      CObjectMaint::GotoLostCell(CPhysicsObj::obj_maint, this, this->m_position.objcell_id);
      this->transient_state &= 0xffffff7f;
  } else {
      // Hooks/Storage/Corpses go through ForceIntoCell
      if (this->weenie_obj != 0) {
          if (weenie_obj->IsHook())    return CPhysicsObj::ForceIntoCell(this, outTx, pos);
          if (weenie_obj->IsStorage()) return CPhysicsObj::ForceIntoCell(this, outTx, pos);
          if (weenie_obj->IsCorpse())  return CPhysicsObj::ForceIntoCell(this, outTx, pos);
      }
      // Honor "do_not_load_cells" sps flag
      if ((sps->flags & 0x20) != 0) tx->cell_array.do_not_load_cells = 1;

      if (CPhysicsObj::CheckPositionInternal(this, outTx, pos, tx, sps) == 0) {
          int32_t r = CPhysicsObj::handle_all_collisions(this, &tx->collision_info, 0, 0);
          return (-r ^ -r ... & 2) + 2;        // BN noise — actually returns 2 or 3
      }
      if (tx->sphere_path.curr_cell == 0) return 3;       // CELL_FAILED
      CPhysicsObj::SetPositionInternal(this, tx);          // 1-arg form: commit
  }
  return 0;        // OK_SPE
}

The 1-arg form (SetPositionInternal(this, tx)) is the one that finally writes the new cell pointer + frame onto the object (it's not in this excerpt — it's the real "commit" routine).


8 — CPhysicsObj::SetPosition (FUN_005160C0)

External wrapper that builds a CTransition, runs SetPositionInternal, and returns. Used by NPC teleport / scatter, NOT by update_object.

005160c0  SetPositionError SetPosition(CPhysicsObj* this, SetPositionStruct* sps) {
  CTransition* tx = CTransition::makeTransition();
  if (tx == 0) return 1;
  CTransition::init_object(tx, this, 0);
  // Init sphere(s) — same pattern as transition()
  CTransition::init_sphere(tx, n, spheres, scale);
  SetPositionError r = CPhysicsObj::SetPositionInternal(this, sps, tx);
  CTransition::cleanupTransition(tx);
  return r;
}

9 — CPhysicsObj::SetPositionSimple (FUN_005162B0)

005162b0  SetPositionError SetPositionSimple(CPhysicsObj* this, Position* pos, int32_t teleport) {
  uint32_t flags = teleport ? 0x1012 : 0x1002;
  SetPositionStruct sps;
  SetPositionStruct::SetPositionStruct(&sps);
  SetPositionStruct::SetPosition(&sps, pos);
  SetPositionStruct::SetFlags(&sps, flags);
  SetPositionError r = CPhysicsObj::SetPosition(this, &sps);
  SetPositionStruct::~SetPositionStruct(&sps);
  return r;
}

10 — CPhysicsObj::set_frame (FUN_00514090)

The "no collision check" frame setter — used inside the substep when origin didn't move OR when the sweep failed and we just snap.

00514090  void set_frame(CPhysicsObj* this, Frame* arg2) {
  Frame newFrame;
  Frame::operator=(&newFrame, arg2);
  if (Frame::IsValid(&newFrame) == 0 && Frame::IsValidExceptForHeading(&newFrame) != 0) {
      // NaN-only-in-quaternion edge case → reset rotation (memset to 0)
      newFrame.qw = 0; newFrame.qx = 0; newFrame.qy = 0; newFrame.qz = 0;
  }
  Frame::operator=(&this->m_position.frame, &newFrame);   // store
  if ((this->state & 0x1000 /* "no parts" */) == 0) {
      if (this->part_array != 0)
          CPartArray::SetFrame(this->part_array, &this->m_position.frame);
  }
  CPhysicsObj::UpdateChildrenInternal(this);              // propagate to children
}

11 — Frame operations

Memory layout (verbatim from acclient.h / inferred from BN offsets)

class Frame {
  Vector3 m_fOrigin;       // +0x00 (12 bytes)
  float qw, qx, qy, qz;    // +0x0C (16 bytes)
  float m_fl2gv[9];        // +0x1C (36 bytes) — 3x3 local-to-global rotation matrix cache
};                         // total 0x40 = 64 bytes

The matrix cache m_fl2gv is the rotation matrix derived from the quaternion. It's recomputed by Frame::cache whenever the quaternion changes.

Frame::operator= (FUN_00425C30) — line 39761

Plain memberwise copy of all 16 floats (origin + quat + 9 matrix entries):

00425c30  Frame& operator=(Frame* this, Frame const& src) {
  this->m_fOrigin = src.m_fOrigin;
  this->qw = src.qw; this->qx = src.qx; this->qy = src.qy; this->qz = src.qz;
  for (int i = 0; i < 9; i++) this->m_fl2gv[i] = src.m_fl2gv[i];
  return *this;
}

Frame::cache (FUN_00534DF0) — line 319353

Rebuilds the m_fl2gv[9] rotation matrix from (qw, qx, qy, qz). Standard quaternion-to-matrix:

00534df0  void Frame::cache(Frame* this) {
  // Use temp doubles to preserve x87 precision
  double tx = this->qx + this->qx;        // 2qx
  double ty = this->qy + this->qy;        // 2qy
  double tz = this->qz + this->qz;        // 2qz
  double wx = this->qw * tx;              // 2qw·qx
  double wy = this->qw * ty;              // 2qw·qy
  double wz = this->qw * tz;              // 2qw·qz
  double xx = this->qx * tx;              // 2qx·qx
  double xy = this->qx * ty;              // 2qx·qy
  double xz = this->qx * tz;              // 2qx·qz
  double yy = this->qy * ty;              // 2qy·qy
  double yz = this->qy * tz;              // 2qy·qz
  double zz = this->qz * tz;              // 2qz·qz

  // Column-major 3x3 stored row-by-row:
  this->m_fl2gv[0] = 1.0 - yy - zz;        //  R00
  this->m_fl2gv[1] = xy + wz;              //  R10
  this->m_fl2gv[2] = xz - wy;              //  R20
  this->m_fl2gv[3] = xy - wz;              //  R01
  this->m_fl2gv[4] = 1.0 - xx - zz;        //  R11
  this->m_fl2gv[5] = yz + wx;              //  R21
  this->m_fl2gv[6] = xz + wy;              //  R02
  this->m_fl2gv[7] = yz - wx;              //  R12
  this->m_fl2gv[8] = 1.0 - xx - yy;        //  R22
}

This is a standard XYZW-quaternion-to-3x3 matrix, but the layout here is transpose of typical glm/Silk row-major. acdream needs to be careful when consuming.

Frame::combine (FUN_005122E0) — line 280355

Most important — multiplication semantics: out = a · b (compose b on top of a).

005122e0  void Frame::combine(Frame* out, Frame const* a, Frame const* b) {
  // ── Origin: rotate b.origin by a's basis, then add a.origin ─────────────
  out->m_fOrigin.x = a->m_fl2gv[0]*b->origin.x
                   + a->m_fl2gv[3]*b->origin.y
                   + a->m_fl2gv[6]*b->origin.z + a->m_fOrigin.x;
  out->m_fOrigin.y = a->m_fl2gv[1]*b->origin.x
                   + a->m_fl2gv[4]*b->origin.y
                   + a->m_fl2gv[7]*b->origin.z + a->m_fOrigin.y;
  out->m_fOrigin.z = a->m_fl2gv[2]*b->origin.x
                   + a->m_fl2gv[5]*b->origin.y
                   + a->m_fl2gv[8]*b->origin.z + a->m_fOrigin.z;

  // ── Quaternion: a.q * b.q (Hamilton product) ────────────────────────────
  // Note: BN swaps some operand orders, but this is the Hamilton product.
  Frame::set_rotate(out,
      a->qw*b->qw - b->qx*a->qx - b->qy*a->qy - b->qz*a->qz,        // qw
      a->qw*b->qx + b->qz*a->qy + b->qw*a->qx - b->qy*a->qz,        // qx
      b->qy*a->qw - b->qz*a->qx + a->qz*b->qx + a->qy*b->qw,        // qy
      b->qy*a->qx + b->qz*a->qw - a->qy*b->qx + a->qz*b->qw);       // qz
}

Frame::set_rotate then normalizes the quaternion and re-runs Frame::cache to refresh m_fl2gv.

Order: combine(out, a, b) means out = a ∘ b — first apply b in local-frame coords, then rotate-and-translate by a. In UpdatePositionInternal step D, this means: outFrame = m_position.frame * localDelta — i.e. take the local-frame motion and lift it into world.

Frame::set_rotate (FUN_00535080) — line 319453

00535080  void Frame::set_rotate(Frame* this, float qw, float qx, float qy, float qz) {
  // Cache old quaternion in case new one is invalid
  float oldQw=this->qw, oldQx=this->qx, oldQy=this->qy, oldQz=this->qz;

  float invLen = 1.0 / sqrt(qw*qw + qx*qx + qy*qy + qz*qz);
  this->qw = qw * invLen;
  this->qx = qx * invLen;
  this->qy = qy * invLen;
  this->qz = qz * invLen;

  if (Frame::IsValid(this) != 0) {
      Frame::cache(this);          // refresh l2gv matrix
  } else {
      // Restore — new quat had NaN
      this->qw=oldQw; this->qx=oldQx; this->qy=oldQy; this->qz=oldQz;
  }
}

Frame::set_heading (FUN_00535E40) — line 320049

Sets heading from a yaw angle (degrees):

00535e40  void Frame::set_heading(Frame* this, float degrees) {
  // BN noise computes a vector from an existing matrix column — irrelevant
  double rad = degrees * 0.017453292519943295;        // π/180
  float sinR = sin(rad);
  float cosR = cos(rad);
  Vector3 heading = { sinR, cosR, 0 };               // +Y is north; rotate CW
  Frame::set_vector_heading(this, &heading);
}

Frame::set_vector_heading (FUN_00535DB0) — line 320030

Sets heading to face a normalized 2D direction vector (rotation around Z-axis):

00535db0  void Frame::set_vector_heading(Frame* this, Vector3 const* dir) {
  Vector3 d = *dir;
  if (AC1Legacy::Vector3::normalize_check_small(&d) != 0) return;
  // Note: AC's heading convention — angle from north (+Y) measured clockwise.
  // 450 - atan2(x, y) normalizes to [0, 360).
  double yawDeg = 450.0 - atan2(d.x, d.y) * 57.295779513082323;
  yawDeg = fmod(yawDeg, 360.0);
  Frame::euler_set_rotate(this, ..., 0, ..., yawDeg * 0.017453292519943295);
}

Frame::rotate (FUN_004525B0) — line 91477

Applies a small rotation increment in local space (used by omega integration):

004525b0  void Frame::rotate(Frame* this, Vector3 const* localOmegaTimesDt) {
  // Lift the local-axis-angle vector to world by the current basis
  Vector3 worldOmegaDt;
  worldOmegaDt.x = this->m_fl2gv[0]*localOmegaTimesDt->x
                 + this->m_fl2gv[3]*localOmegaTimesDt->y
                 + this->m_fl2gv[6]*localOmegaTimesDt->z;
  worldOmegaDt.y = this->m_fl2gv[1]*localOmegaTimesDt->x
                 + this->m_fl2gv[4]*localOmegaTimesDt->y
                 + this->m_fl2gv[7]*localOmegaTimesDt->z;
  worldOmegaDt.z = this->m_fl2gv[2]*localOmegaTimesDt->x
                 + this->m_fl2gv[5]*localOmegaTimesDt->y
                 + this->m_fl2gv[8]*localOmegaTimesDt->z;
  Frame::grotate(this, &worldOmegaDt);          // global-frame rotation
}

Frame::set_origin — does it exist?

No — search of acclient_2013_pseudo_c.txt finds zero hits for Frame::set_origin. Origin is set by direct member assignment (f.m_fOrigin = newOrigin) or via Frame::operator=. Note: the named PDB does not list a public mutator for origin alone.

Frame::is_zero — does it exist?

No — search returns zero hits for Frame::is_zero. The is_zero method exists on AC1Legacy::Vector3 (e.g. Vector3::is_zero(&this->m_velocityVector) at line 283667), and is applied to Frame::m_fOrigin indirectly via Vector3::operator==(&zeroVec, &frame.origin).


12 — Position::ctor, Position::distance, Position::get_offset

Position::Position (FUN_00424AB0) — default ctor

Sets vtable, zero objcell_id, identity Frame.

Position::Position(Position*, uint32_t cellId, Frame*) — line 91542

00452780  void Position::Position(Position* this, uint32_t cellId, Frame* frame) {
  this->vtable     = 0x796910;
  this->objcell_id = cellId;
  Frame::operator=(&this->frame, frame);
}

Position::Position(Position*, Position const*) — line 91655 (copy ctor)

Just calls Frame::operator= on the embedded frame and copies cellId.

Position::get_offset — line 272088

Cell-aware vector offset (this → arg3) in landblock-global coordinates.

00509f60  Vector3* Position::get_offset(Position const* this, Vector3* out, Position const* other) {
  Vector3 blockOffset;
  // Compute the world offset between the two cell origins (uses landblock IDs).
  LandDefs::get_block_offset(&blockOffset, this->objcell_id, other->objcell_id);
  // out = (other.origin + blockOffset) - this.origin
  out->x = (blockOffset.x + other->frame.origin.x) - this->frame.origin.x;
  out->y = (blockOffset.y + other->frame.origin.y) - this->frame.origin.y;
  out->z = (blockOffset.z + other->frame.origin.z) - this->frame.origin.z;
  return out;
}

This is what acdream's Position.GetOffset mirrors. Critical: cells in different landblocks must be reconciled via LandDefs::get_block_offset before subtracting origins.

Position::distance (FUN_005A94B0) — line 438258

005a94b0  Vector3* Position::distance(Position const* this, Position const* other) {
  Vector3 r;
  Position::get_offset(this, &r, other);
  // (BN noise — actually returns sqrtf of the offset squared)
  return r;       // caller takes magnitude
}

Note: the BN decomp shows result->z; result->y; result->x; followed by return result — these dereferences load the floats but don't produce output here. The actual return value is the raw offset vector from get_offset; the caller computes .Length(). ACE's Position.Distance does this correctly.


13 — Substepping algorithm summary (the key answer)

dt = currentTime - LastUpdateTime

if (dt < EPSILON)        return;   // < 0.0002s — too small, defer
if (dt > HugeQuantum)    return;   // > 2.0s   — stale, discard, advance update_time

while (dt > MaxQuantum):            // 0.1 s
    PhysicsTimer.curr_time += MaxQuantum
    UpdateObjectInternal(MaxQuantum)
    dt -= MaxQuantum

if (dt > MinQuantum):               // 0.0333 s (1/30)
    PhysicsTimer.curr_time += dt
    UpdateObjectInternal(dt)         // remainder, anywhere in (1/30, 0.1]

LastUpdateTime = PhysicsTimer.curr_time

Observations:

  1. Retail processes every frame, not just every 30 Hz. The first guard is EPSILON, not MinQuantum. ACE flipped this to < TickRate (= 1/30) for server CPU savings — that's a divergence, not retail-faithful.
  2. dt below MinQuantum but above EPSILON → no simulation that frame, but update_time IS advanced to current time (line 284004). That means the next frame's dt is small again — accumulation is implicit, not explicit. There is no carry-over residue.
  3. Between MinQuantum and MaxQuantum: a single substep for the full dt. (60-fps client => dt ≈ 0.0167 s — wait, that's BELOW MinQuantum.) Actually at 60 fps the second guard (> MinQuantum) is also FALSE, so nothing runs. This is consistent with retail running its physics tick at 30 Hz in MainProc. Retail's MainProc ticks at ~30 Hz on most hardware, not 60 Hz. acdream renders at 60 Hz but ticks physics at... whatever wall-clock dt comes through.
  4. Between MaxQuantum and HugeQuantum: chunked substepping at fixed 0.1 s, then 1 final remainder if it exceeds MinQuantum. This handles frame-stutter / lag spikes (e.g. world load).
  5. process_hooks runs once per substep (inside UpdatePositionInternal), so its rate scales with substep count — important for FPHook fade timers.
  6. The collision sweep (transition) runs once per substep, which is why bypassing it (env-var path bug) caused the "staircase" effect on slopes.

What this means for LastUpdateTime advancement

After every substep loop, LastUpdateTime = PhysicsTimer.curr_time. PhysicsTimer.curr_time was incremented inside the loop by MaxQuantum per substep + the final remainder. It accumulates ONLY consumed time — if the early-< EPSILON guard fires, update_time is set to Timer::cur_time directly, dropping any unconsumed micro-fragment.


14 — Cross-check against acdream's port

PhysicsBody.update_object (src/AcDream.Core/Physics/PhysicsBody.cs:404-435):

acdream uses the threshold values correctly (MinQuantum=1/30, MaxQuantum=0.1, HugeQuantum=2.0) and has the substep loop. But it gates on dt < MinQuantum for the early return, not < EPSILON. This matches ACE's port (which uses TickRate=1/30) but diverges from retail, which uses EPSILON=0.0002.

Practical effect of the divergence:

At 60 fps (dt = 0.0167 s), retail would: pass the EPSILON gate → fail the loop gate (0.0167 < MaxQuantum=0.1) → fail the remainder gate (0.0167 < MinQuantum=0.0333) → bump update_time and return. No simulation ran, but time was consumed. Next frame: dt = 0.0167 again. Retail effectively sub-samples to 30 Hz, but does it through threshold-based skipping rather than explicit accumulation. Net effect: physics ticks at ~30 Hz on a 60-Hz render loop.

acdream's port: at 60 fps, dt = 0.0167 s, fails first gate (< MinQuantum = 0.0333), returns immediately WITHOUT updating LastUpdateTime. Next frame: dt = 0.0334 s, passes the first gate, runs UpdatePhysicsInternal(0.0334) once. Net effect: physics ticks at ~30 Hz, same outcome, but via accumulation. Equivalent functionally; minor structural divergence.

Recommendation: the acdream port is fine as-is for acdream (no behavioral difference at 30 Hz target), but the comment at line 395 (if dVar1 < MinQuantum → return) should note that retail uses EPSILON; the change is intentional alignment with ACE's optimization.

GameWindow.cs per-tick remote motion path (line 6541-6553):

The comment "rely on PhysicsBody.update_object here — its MinQuantum 30 fps gate" is accurate about acdream's port but slightly misleading about retail's behavior. Retail does NOT have a 30 Hz gate in update_object; it has a substep loop that effectively delivers 30 Hz simulation by way of the inner thresholds. The manual omega integration in GameWindow (line 6553-6559) is a workaround for a different reason — the body's quaternion-omega integration doesn't run when the body's update_object's loop body doesn't run, but acdream's render-tick path needs orientation continuity at 60 fps. This is a legitimate divergence forced by the env-var-path architecture; it's not a retail mismatch.

The real bug (Commit B fix at line 6190): the env-var path was missing the ResolveWithTransition call (port of find_transitional_position) that retail runs once per substep. Restoring it (line 6220) brought the env-var path back in line with retail.


15 — Open questions / follow-ups

  • Frame::set_origin and Frame::is_zero don't exist as named symbols. The conventions are: direct field write for origin, and Vector3::is_zero on frame.m_fOrigin for the test. Confirm acdream's port uses the same conventions (no need for these methods on the Frame type).
  • update_object_server does not exist. ACE's distinction between client and server update is not present in retail. The retail client and the retail server (which acdream emulates) probably both run the same code path; if so, acdream needs only one update_object.
  • The early-return gate divergence (EPSILON vs MinQuantum) is functionally invisible at 30 Hz physics target but worth documenting in the port comment so future readers know it's an intentional ACE-style optimization, not a retail-faithful copy.
  • process_hooks linked-list (PhysicsObjHook) is not yet ported in acdream. acdream has anim-hook routing (AnimationHookRouter) but no equivalent of FPHook / TranslucencyHook / VisibilityHook persistent linked-list framework. Phase that uses translucency animations or scale fades will need this.