320 lines
11 KiB
Markdown
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.
|