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