Rewrites src/AcDream.Core/Physics/InterpolationManager.cs from the spec in docs/research/2026-05-04-l3-port/04-interp-manager.md. Public API preserved (Vector3-returning AdjustOffset, Enqueue, Clear, IsActive, Count) so PositionManager + GameWindow callers continue to compile; internals are full retail spec. Bug fixes vs prior port (audit 04-interp-manager.md § 7): #1 progress_quantum accumulates dt (sum of frame deltas), not step magnitude. Retail line 353140; the prior port's `+= step` made the secondary stall ratio meaningless. #3 Far-branch Enqueue (dist > AutonomyBlipDistance = 100m) sets _failCount = StallFailCountThreshold + 1 = 4, so the next AdjustOffset call's post-stall check fires an immediate blip-to- tail snap. Retail line 352944. Prior port silently drifted toward far targets at catch-up speed instead of teleporting. #4 Secondary stall test ports the retail formula verbatim: cumulative / progress_quantum / dt < CREATURE_FAILED_INTERPOLATION_PERCENTAGE. Audit notes the units are 1/sec (likely Turbine bug or x87 FPU misread by Binary Ninja) — mirrored byte-for-byte regardless. #5 Tail-prune is a tail-walking loop, not a single-tail compare. Multiple consecutive stale tail entries within DesiredDistance (0.05 m) of the new target collapse together. Retail line 352977. #6 Cap-eviction at the HEAD when count reaches 20 (already correct in the prior port; verified). New API: Enqueue gains an optional `currentBodyPosition` parameter so the far-branch detection can reference the body when the queue is empty. Backward-compatible (default null = pre-far-branch behavior). UseTime collapsed into AdjustOffset's tail (post-stall blip check) since acdream has no per-tick UseTime call separate from adjust_offset; identical semantic outcome. State fields renamed to retail names with sentinel values: _frameCounter, _progressQuantum, _originalDistance (init = 999999f sentinel per retail line 0x00555D30 ctor), _failCount. Tests: - 17/17 InterpolationManagerTests green. - New test Enqueue_FarBranch_PrearmsImmediateBlipOnNextAdjustOffset pins the bug #3 fix: enqueueing 150 m away triggers a same-tick blip (delta length ≈ 150 m), and the queue clears. Spec tree: 17 research docs (00–14) under docs/research/2026-05-04-l3-port/. 00-master-plan + 00-port-plan describe the 8-phase rollout. 01-per-tick, 03-up-routing, 04-interp-manager, 05-position-manager-and-partarray, 06-acdream-audit, 14-local-player-audit are the L.3 spec used by this commit and the M2 follow-up. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
40 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? (early-return at EPSILON, NOT MinQuantum — retail processes every frame)
- How is
LastUpdateTimeadvanced? (always toPhysicsTimer::curr_timeafter the loop) - 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 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:
- 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
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:
- Retail processes every frame, not just every 30 Hz. The first guard is
EPSILON, notMinQuantum. ACE flipped this to< TickRate(= 1/30) for server CPU savings — that's a divergence, not retail-faithful. - dt below MinQuantum but above EPSILON → no simulation that frame, but
update_timeIS 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. - 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 inMainProc. Retail'sMainProcticks at ~30 Hz on most hardware, not 60 Hz. acdream renders at 60 Hz but ticks physics at... whatever wall-clock dt comes through. - 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).
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. 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_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.- 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_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.