acdream/docs/research/2026-05-04-l3-port/08-update-object-substep-and-frame.md
Erik f02b995e4f feat(physics): port retail projectile integration
Add a pure Core projectile driver for retail clock quanta, final-state acceleration, 50-unit velocity clamping, world-space angular integration, full-3D AlignPath, and oriented Setup-local sphere sweeps. Preserve exact success versus set_frame-only failure semantics, independent Contact/OnWalkable state, and full-cell identity across all landblock directions.

Port OBJECTINFO::missile_ignore with explicit weenie/creature metadata, remove the invented transition-step cap, retain PathClipped without arming PerfectClip, and keep authoritative target identity optional. Add malformed-shape/clock, thin-wall, terrain, target-exclusion, frame-spike, failure-state, and boundary conformance coverage.

Retire TS-2 and synchronize the architecture, milestones, roadmap, research oracle, and durable physics memory.

Co-Authored-By: Codex <noreply@openai.com>
2026-07-14 11:43:11 +02:00

38 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? (micro-fragments at EPSILON are consumed; remainders at or below MinQuantum accumulate)
  • How is LastUpdateTime advanced? (to the consumed PhysicsTimer::curr_time, not unconditionally to the wall clock)
  • 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 global loads to 0.0. Matching-client disassembly resolves them as 1/30 second, 0.2 seconds, and 2.0 seconds. The 0.000199999995f (= EPSILON = 0.0002) constant IS visible in the decomp (line 283996) — it is the micro-fragment 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). Matching disassembly resolves 0.2.
  if (dt > 0.0f /* MaxQuantum=0.2 */) {
      do {
          PhysicsTimer::curr_time += /* MaxQuantum */ 0.2;
          CPhysicsObj::UpdateObjectInternal(this_3, /* MaxQuantum */ 0.2f);
          dt -= /* MaxQuantum */ 0.2;
      } while (dt > /* MaxQuantum */ 0.2);
  }

  // ── 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 — matching-client values

Recovered from the v11.4186 matching executable and cross-checked against the named control flow:

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 0x3E4CCCCD 0.2f (5 fps) catch-up substep cap — BN-corrupted to 0.0 in pseudo-C
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 reads non-zero globals. Matching-client disassembly confirms these are MaxQuantum=0.2 in the loop body and MinQuantum=1/30 in the final-remainder guard. ACE's server-side 0.1-second value is not the retail-client oracle here.

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

Replaces the frame rotation so local forward (+Y) faces a full three-dimensional direction with zero roll. A zero/small vector leaves the existing rotation unchanged. The named lift preserves the X/Y compass calculation and Z-driven asin elevation path, although it mangles the euler_set_rotate argument list:

00535db0  void Frame::set_vector_heading(Frame* this, Vector3 const* dir) {
  Vector3 d = *dir;
  if (AC1Legacy::Vector3::normalize_check_small(&d) != 0)
      return;                         // preserve the current frame

  // 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);

  // Full 3-D elevation; not a yaw-only rotation. The retail Euler convention
  // places normalized d exactly on the resulting frame's local +Y axis.
  double elevation = asin(d.z);
  Frame::euler_set_rotate(this, zeroRoll, elevation, yawDeg * DEG_TO_RAD);
}

The conformance contract is therefore transform(UnitY, rotation) == normalize(dir) for horizontal, elevated, downward, and near-vertical vectors. The exact-vertical case uses retail's deterministic zero-compass fallback.

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
PhysicsTimer.current = LastUpdateTime

if (dt <= EPSILON):                 // <= 0.0002s — micro-fragment
    LastUpdateTime = currentTime    // consume without simulation
    return
if (dt > HugeQuantum):              // > 2.0s — stale gap
    LastUpdateTime = currentTime    // discard without simulation
    return

while (dt > MaxQuantum):            // 0.2 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. The EPSILON branch and MinQuantum remainder are different. A true micro-fragment is consumed immediately. A remainder above EPSILON but at or below 1/30 second runs no simulation and leaves PhysicsTimer.current at the prior update time, so it accumulates into the next call.
  2. At a 60 Hz render rate, the first ~0.0167-second call remains accumulated; the next call sees ~0.0334 seconds and executes one physics quantum. This yields the retail ~30 Hz physics cadence without an explicit accumulator field.
  3. Between MinQuantum and MaxQuantum, retail executes one substep for the full elapsed time.
  4. Between MaxQuantum and HugeQuantum, retail replays fixed 0.2-second catch-up quanta, then one final remainder only when it exceeds MinQuantum.
  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. It advances only by simulated quanta. The EPSILON and HugeQuantum guards instead assign Timer::cur_time directly because those fragments are deliberately consumed or discarded.


14 — Cross-check against acdream's port

PhysicsBody.update_object (src/AcDream.Core/Physics/PhysicsBody.cs) now carries the same clock contract: EPSILON/HugeQuantum guards consume wall-clock time, the 0.2-second loop advances a local physics clock, and a remainder at or below MinQuantum remains accumulated. ProjectilePhysicsStepper applies the same clock around the full integration-and-transition sequence.

The live projectile path does not use the older render-only remote-motion integration. It invokes a transition for every consumed quantum, matching UpdateObjectInternal, so a catch-up frame cannot tunnel merely because several quanta were required.


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