11 KiB
R6 retail object-update order
Date: 2026-07-19 Retail oracle: Asheron's Call v11.4186 (September 2013 EoR)
Scope
This note pins the control-flow order needed by R6. It deliberately separates two kinds of animation-hook work:
- the immediate
AnimDoneHookcontrol path, whose result changes the motion queues observed later in the same object update; and - all other captured animation hooks, which acdream currently defers until
the final root/part pose has been published. This includes presentation
hooks such as particles and sounds, but also semantically relevant
CallPES, default-script chaining, and translucency hooks.
That split is an acdream presentation boundary. Authored FIFO order is
preserved within acdream's deferred non-AnimationDone stream, but moving that
stream past retail's manager tail changes manager-relative timing; that is the
registered TS-50 divergence.
Named-retail sources
CPhysicsObj::process_hooks0x00511550CPhysics::UseTime0x00509950CPhysicsObj::animate_static_object0x00513DF0CPhysicsObj::UpdatePositionInternal0x00512C30CPhysicsObj::UpdateObjectInternal0x005156B0CPhysicsObj::update_object0x00515D10CPartArray::AnimationDone0x00517D30CPartArray::HandleMovement0x00517D60AnimDoneHook::Execute0x00526C20CPhysicsObj::Hook_AnimDone0x0050FDA0
Primary text:
docs/research/named-retail/acclient_2013_pseudo_c.txt.
Addresses and names are also pinned in
docs/research/named-retail/symbols.json.
Corrected top-level order
An older roadmap diagram put process_hooks after the manager tail. The named
retail function body disproves that. UpdatePositionInternal processes hooks
before it returns to UpdateObjectInternal; the transition/commit and every
manager therefore observe completed animation state in the same object tick.
CPhysicsObj::update_object
-> quantum gate/subdivision
-> CPhysicsObj::UpdateObjectInternal
-> CPhysicsObj::UpdatePositionInternal
-> CPartArray::Update
-> PositionManager::adjust_offset
-> Frame::combine
-> CPhysicsObj::UpdatePhysicsInternal
-> CPhysicsObj::process_hooks <-- completion drain
-> transition / SetPositionInternal
-> DetectionManager::CheckDetection
-> TargetManager::HandleTargetting
-> MovementManager::UseTime
-> CPartArray::HandleMovement
-> PositionManager::UseTime
-> ParticleManager::UpdateParticles
-> ScriptManager::UpdateScripts
CPhysics::UseTime
-> ordinary object table in the order above
-> static_animating_objects
-> CPhysicsObj::animate_static_object
-> CPartArray::Update
-> UpdatePartsInternal
-> UpdateChildrenInternal
-> ScriptManager::UpdateScripts
-> ParticleManager::UpdateParticles
-> CPhysicsObj::process_hooks
Pseudocode
CPhysicsObj::update_object
if parent != null or cell == null or state contains Frozen:
clear Active transient state
return
refresh active/view-distance state
elapsed = current_physics_time - last_update_time
if elapsed <= FrameEpsilon (0.000199999995 seconds):
last_update_time = current_physics_time
return
if elapsed > HugeQuantum:
last_update_time = current_physics_time
return
while elapsed > MaxQuantum:
last_update_time += MaxQuantum
UpdateObjectInternal(MaxQuantum)
elapsed -= MaxQuantum
if elapsed > MinQuantum:
last_update_time = current_physics_time
UpdateObjectInternal(elapsed)
The comparisons are strict (>), not >=. Elapsed time at or below the
roughly 0.2-millisecond frame epsilon is explicitly rebased/discarded. Above
that epsilon, a remainder at or below MinQuantum stays in the clock for a
later call; MaxQuantum controls subdivision and HugeQuantum controls stale
discard. These are separate gates.
UpdateObjectInternal then branches on the transient Active bit. An inactive
object skips position integration, hook processing, Detection, Target,
Movement, PartArray, and PositionManager, but still advances its
ParticleManager and ScriptManager tails. A Hidden object that remains Active
is different: it enters UpdatePositionInternal, suppresses only PartArray
advance and physics integration, and retains PositionManager adjustment,
hook processing, and the complete manager tail.
CPhysicsObj::UpdatePositionInternal(dt, candidate)
delta = identity Frame
if state does not contain Hidden:
if part_array != null:
part_array.Update(dt, delta)
if transient state contains OnWalkable:
delta.origin *= object_scale
else:
delta.origin = zero
if position_manager != null:
position_manager.adjust_offset(delta, dt)
candidate = combine(current_frame, delta)
if state does not contain Hidden:
UpdatePhysicsInternal(dt, candidate)
process_hooks()
Hidden suppresses PartArray time advance and physics integration. It does not
suppress PositionManager::adjust_offset, hook processing, or the later
manager tail.
CPhysicsObj::process_hooks
for each physics hook present in the linked list walk:
completed = hook.Execute(owner)
if completed:
unlink and destroy that hook
else:
retain it for a later object update
for i from zero while i < current animation-hook count:
animation_hooks[i].Execute(owner)
// the bound is live: a hook appended during execution can run in this drain
shrink animation-hook storage and set the count to zero
The physics-hook walk snapshots each current node's next pointer before
execution. A completed hook is removed; an incomplete hook remains linked.
The animation-hook loop deliberately rereads m_num after each callback, so
reentrant append is consumed in the same drain. Neither collection is an
unconditional snapshot-and-clear queue.
For an AnimDoneHook:
AnimDoneHook::Execute(owner)
-> owner.Hook_AnimDone()
-> owner.part_array.AnimationDone(success = true)
-> MotionTableManager.AnimationDone(true)
-> possibly MotionDone on the matching pending motion
Consequently, targeting and movement managers in the same object tick must see the post-completion queue state.
CPhysicsObj::UpdateObjectInternal(dt) manager tail
candidate = current frame
UpdatePositionInternal(dt, candidate)
if candidate moved:
transition/sweep from current frame to candidate
commit accepted position and collision response
if detection_manager != null:
detection_manager.CheckDetection()
if target_manager != null:
target_manager.HandleTargetting()
if movement_manager != null:
movement_manager.UseTime()
if part_array != null:
part_array.HandleMovement()
// tailcalls MotionTableManager.UseTime /
// CheckForCompletedMotions
if position_manager != null:
position_manager.UseTime()
if particle_manager != null:
particle_manager.UpdateParticles(dt)
if script_manager != null:
script_manager.UpdateScripts(dt)
The tail is reached for both visible and Hidden objects when the object quantum runs and the object is Active. An inactive object skips this position/manager body but still reaches Particle then Script. A missing manager is simply skipped. No manager is reordered around another manager based on local/remote/player/NPC classification.
CPhysicsObj::animate_static_object
if cell == null:
return
elapsed = current_time - update_time
if elapsed <= FrameEpsilon (0.000199999995 seconds):
update_time = current_time
return
if elapsed > HugeQuantum:
update_time = current_time
return
if part_array != null:
if state contains HasDefaultAnim:
part_array.Update(elapsed)
rotate root by the current angular vector
UpdatePartsInternal()
UpdateChildrenInternal()
if state contains HasDefaultScript and script_manager != null:
script_manager.UpdateScripts()
if particle_manager != null:
particle_manager.UpdateParticles()
process_hooks()
update_time = current_time
The static workset has no ordinary-object MinQuantum remainder gate or
MaxQuantum subdivision loop: it uses only the frame epsilon and
HugeQuantum, then passes the complete admitted elapsed time to PartArray.
It therefore has a distinct Script → Particle → hooks tail, not ordinary
objects' Particle → Script tail. acdream currently shares one
ParticleSystem and one PhysicsScriptRunner across every owner and advances
them once after the complete ordinary/static worksets. That is the registered
TS-51 adaptation and must not be silently reordered by an ownership-only
extraction.
acdream conformance boundary
The first R6 cutover uses one explicit coordinator for the ordered manager
tail. Local and remote owners provide callbacks for the managers they possess;
unsupported DetectionManager is represented by an absent callback rather
than an invented behavior.
Animation-hook capture performs AnimationDone control effects immediately,
at the process_hooks boundary. Every other captured animation hook currently
remains deferred until the final root/part poses are published, including
particle/audio/light/translucency hooks, CallPES, and default-script
chaining. Deferred routing must never invoke AnimationDone a second time and
must never own
MotionTableManager.UseTime; that belongs only to the PartArray manager-tail
slot.
Conformance cases
- Animation completion is visible to TargetManager and MovementManager in the same object tick.
- Target runs before Movement; Movement runs before PartArray; PartArray runs before PositionManager.
- A missing manager does not shift or duplicate another callback.
- Parented, cell-less, and Frozen objects return before clock admission.
- Inactive objects skip position/hooks/manager time but retain Particle then Script; Hidden + Active objects skip sequence/physics but retain adjustment, hooks, and the manager tail.
- A non-
AnimationDonehook sees a pose published after capture, while anAnimDoneHookchanges motion state at capture time. - Deferred hook drain never completes an animation twice and never performs
a zero-tick motion sweep; representative
CallPES, default-script, and translucency hooks retain their current deferred path. - An incomplete physics hook remains linked, and an animation hook appended by another animation hook executes in the same retail drain.
- Static objects retain their distinct Script → Particle → hooks order.
- Ordinary epsilon/Min/Max/Huge boundaries and static epsilon/Huge boundaries preserve their separate strict retail comparisons.
Cross-reference notes
ACE's PhysicsObj.cs corroborates ordinary Particle → Script and static
Script → Particle → hooks order. It does not corroborate the retail clock
or hook-lifetime gates: ACE uses its server TickRate/Min/Max policy and
clears physics hooks unconditionally, unlike retail's epsilon/Huge behavior
and completion-based hook retention. ACViewer exposes the same client-shaped
methods and manager order but shares ancestry with ACE, so that agreement is
corroboration rather than a second oracle. holtburger's native protocol crates
have no client physics-loop equivalent (its vendored ACViewer copy is not
independent). The retail binary above is authoritative for every boundary.
The existing
RemoteChaseEndToEndHarnessTests hand-codes acdream's pre-R6 order and is
therefore a migration target, not an oracle.