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

320 lines
11 KiB
Markdown

# 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.
```text
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`
```text
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)`
```text
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`
```text
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`:
```text
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
```text
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`
```text
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.