# 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 `PhysicsObjHook`s + `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.
