# 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 `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 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::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`): 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.