Add a pure Core projectile driver for retail clock quanta, final-state acceleration, 50-unit velocity clamping, world-space angular integration, full-3D AlignPath, and oriented Setup-local sphere sweeps. Preserve exact success versus set_frame-only failure semantics, independent Contact/OnWalkable state, and full-cell identity across all landblock directions. Port OBJECTINFO::missile_ignore with explicit weenie/creature metadata, remove the invented transition-step cap, retain PathClipped without arming PerfectClip, and keep authoritative target identity optional. Add malformed-shape/clock, thin-wall, terrain, target-exclusion, frame-spike, failure-state, and boundary conformance coverage. Retire TS-2 and synchronize the architecture, milestones, roadmap, research oracle, and durable physics memory. Co-Authored-By: Codex <noreply@openai.com>
38 KiB
L.3 port — update_object substepping + Frame operations
Source: docs/research/named-retail/acclient_2013_pseudo_c.txt (Sept 2013 EoR PDB-named decompilation, BinaryNinja pseudo-C). Cross-checked against ACE's port (references/ACE/Source/ACE.Server/Physics/PhysicsObj.cs, PhysicsGlobals.cs).
This document extracts the per-tick variable-dt substepping algorithm and the Frame composition primitives that drive the per-frame physics integration. It answers:
- What is the substepping algorithm? (HugeQuantum discard → MaxQuantum slicer loop → MinQuantum remainder)
- What happens for very small dt? (micro-fragments at EPSILON are consumed; remainders at or below MinQuantum accumulate)
- How is
LastUpdateTimeadvanced? (to the consumedPhysicsTimer::curr_time, not unconditionally to the wall clock) - What does
process_hooksdo? (iterates linked-list ofPhysicsObjHooks +anim_hooksper frame, executing & removing finished ones) Frame::combinesemantics:out = a · b— Frame transform composition (rotate b's origin by a's basis, then add a's origin; quaternion producta.q * b.qfor 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:
- Build identity local
Frame(offsetFrame). UpdatePositionInternal(this, dt, &offsetFrame)— integrate motion into the frame.- If origin changed and we have collidable spheres → run
transition()(collision sweep). - If sweep succeeds → commit via
SetPositionInternal; cached_velocity = (deltaPos / dt). - If sweep fails → snap to
offsetFramedirectly; zero velocity. - Tick managers (Detection, Target, Movement, PositionManager).
- Tick CPartArray::HandleMovement (per-part frame propagation).
- 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). EachExecutereturns 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:
- Computes step count (
calc_num_steps) —dt-derivedbased on offset length and sphere radius. - Loops, advancing the sphere along the offset, calling
transitional_inserteach step. - 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:
- 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.currentat the prior update time, so it accumulates into the next call. - 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.
- Between MinQuantum and MaxQuantum, retail executes one substep for the full elapsed time.
- Between MaxQuantum and HugeQuantum, retail replays fixed 0.2-second catch-up quanta, then one final remainder only when it exceeds MinQuantum.
process_hooksruns once per substep (insideUpdatePositionInternal), so its rate scales with substep count — important for FPHook fade timers.- 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_originandFrame::is_zerodon't exist as named symbols. The conventions are: direct field write for origin, andVector3::is_zeroonframe.m_fOriginfor the test. Confirm acdream's port uses the same conventions (no need for these methods on theFrametype).update_object_serverdoes 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 oneupdate_object.process_hookslinked-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.