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

824 lines
40 KiB
Markdown

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