acdream/docs/research/2026-07-19-r6-update-object-order-pseudocode.md

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 AnimDoneHook control 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_hooks 0x00511550
  • CPhysics::UseTime 0x00509950
  • CPhysicsObj::animate_static_object 0x00513DF0
  • CPhysicsObj::UpdatePositionInternal 0x00512C30
  • CPhysicsObj::UpdateObjectInternal 0x005156B0
  • CPhysicsObj::update_object 0x00515D10
  • CPartArray::AnimationDone 0x00517D30
  • CPartArray::HandleMovement 0x00517D60
  • AnimDoneHook::Execute 0x00526C20
  • CPhysicsObj::Hook_AnimDone 0x0050FDA0

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

  1. Animation completion is visible to TargetManager and MovementManager in the same object tick.
  2. Target runs before Movement; Movement runs before PartArray; PartArray runs before PositionManager.
  3. A missing manager does not shift or duplicate another callback.
  4. Parented, cell-less, and Frozen objects return before clock admission.
  5. 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.
  6. A non-AnimationDone hook sees a pose published after capture, while an AnimDoneHook changes motion state at capture time.
  7. 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.
  8. An incomplete physics hook remains linked, and an animation hook appended by another animation hook executes in the same retail drain.
  9. Static objects retain their distinct Script → Particle → hooks order.
  10. 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.