fix(motion): restore retail object manager order

Process animation completion at the retail process_hooks boundary, then run targeting, movement, PartArray completion, and PositionManager in the named UpdateObjectInternal order for local, remote, hidden, and position-less animated objects. Retire TS-42 with deterministic conformance coverage.\n\nCo-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-19 21:40:14 +02:00
parent 0f996db747
commit c5ab99081c
17 changed files with 493 additions and 98 deletions

View file

@ -46,7 +46,7 @@ stage; **LOW** = dormant/textual.
| G3 | **update_internal boundary model: clamp-to-`high_frame`/`low_frame` + leftover-time carry, boundary test `floor(frameNum) > high_frame` (i.e. ≥ high+1).** acdream tests `newPos >= maxBoundary - FrameEpsilon` and clamps `_framePosition = maxBoundary - FrameEpsilon` — epsilon-shifted boundary, different clamp target, no strict retail equivalence at exact-integer landings. This is the #61 "link→cycle boundary flash" bug class. | `0x005255d0` body (lines 301839-302235); ACE `Sequence.cs:366-377` (fwd), `:394-406` (rev) | `AnimationSequencer.cs:894,900` (`maxBoundary - FrameEpsilon`) | **BLOCKER** |
| G4 | **`safety=64` loop cap** — retail has none; termination comes from `frameTimeElapsed` shrinking + the `frametime == 0` branch returning. Mandate says delete bandaids on cutover. | ACE `Sequence.cs:351-443` (no cap); decomp `0x005255d0` (while-true loop) | `AnimationSequencer.cs:872-874` | MED (delete in R1 core) |
| G5 | **AnimDone gate is a LIST-STRUCTURE test, not a node flag.** Retail queues the global `anim_done_hook` singleton when `animDone && hook_obj != null && (anim_list.head_ 4) != first_cyclic` — i.e. "the old head has been consumed and we're not already in the cyclic tail". acdream pushes `AnimationDoneSentinel` gated on `!_currNode.Value.IsLooping` — a per-node flag that acdream itself invented (retail nodes have no IsLooping). Different gate ⇒ different MotionDone timing. R2's `CheckForCompletedMotions → AnimationDone → MotionDone` chain consumes this signal; it must be retail-exact in R1. | `0x00525943-0x00525968` (verified raw this pass); AnimDoneHook singleton at data `0x0081d9fc`, Execute `0x00526c20``Hook_AnimDone 0x0050fda0``CPartArray::AnimationDone(1)` (§18a) | `AnimationSequencer.cs:1129` (`AnimationDoneSentinel`), Advance's `!IsLooping` gate (r1-acdream map row "AnimationDone hook") | **HIGH** |
| G6 | **Two-stage hook dispatch.** Retail `execute_hooks` QUEUES matching `CAnimHook*` into `CPhysicsObj.anim_hooks` (SmartArray); a separate `CPhysicsObj::process_hooks` drains + `Execute()`s once per physics tick then resets `m_num=0`. acdream's `_pendingHooks` + `ConsumePendingHooks()` is structurally similar (queue then drain) but the drain point is GameWindow's render-tick loop immediately after `Advance` — not positioned per retail's `UpdateObjectInternal` order (`process_hooks` runs LAST, after MovementManager.UseTime etc.). R1 must expose the queue through a host seam so R6 can place the drain correctly. | `execute_hooks 0x00524830` (line 300780), `add_anim_hook 0x00514c20` (line 282906), `process_hooks 0x00511550` (line 279431) (§18) | `AnimationSequencer.cs:1371-1388` (`ExecuteHooks``_pendingHooks`), `GameWindow.cs:9882` (drain) | MED for R1 (seam), HIGH by R6 |
| G6 | **Two-stage hook dispatch.** Retail `execute_hooks` QUEUES matching `CAnimHook*` into `CPhysicsObj.anim_hooks` (SmartArray); `CPhysicsObj::process_hooks` executes them inside `UpdatePositionInternal`, before the transition and manager tail, then resets `m_num=0`. acdream's `_pendingHooks` + `ConsumePendingHooks()` is structurally similar. R6 processes semantic `AnimationDone` at capture time while retaining pose-dependent presentation delivery until current root/part poses are published. | `execute_hooks 0x00524830` (line 300780), `add_anim_hook 0x00514c20` (line 282906), `process_hooks 0x00511550` (line 279431) (§18); corrected order pinned in `2026-07-19-r6-update-object-order-pseudocode.md` | `AnimationSequencer.cs` pending-hook queue; `AnimationHookFrameQueue.Capture` semantic completion and `Drain` presentation delivery | RETIRED by R6 semantic-order cutover 2026-07-19 |
| G7 | **`apply_physics` + Frame-target root motion is unwired.** Retail: `update(quantum, Frame*)` accumulates `velocity*signed_quantum` into `frame.m_fOrigin` and `omega*signed_quantum` via `Frame::rotate`, with `signed_quantum = copysign(fabs(arg3), arg4)`; per crossed frame the quantum is `1.0/framerate` signed by elapsed. acdream has NO `apply_physics`: velocity/omega are surfaced as `CurrentVelocity`/`CurrentOmega` properties consumed by external movement code, and pos-frame root motion accumulates into `_rootMotionPos/_rootMotionRot` that **nothing drains** (`ConsumeRootMotionDelta()` has zero callers). | `0x00524ab0` (line 300955, §19); `Frame::rotate 0x004525b0` | `AnimationSequencer.cs:975-979` region (`ConsumeRootMotionDelta`, dead); `CurrentVelocity/CurrentOmega` consumers `GameWindow.cs:9331-9334`, `:12917` | **HIGH** — this is retail's entire physics-from-animation mechanism; R6's `CPartArray.Update → adjust_offset → Frame.combine` tick order needs it |
| G8 | **Empty-list physics-only fallback.** Retail `update`: if `anim_list` empty and `frame != null``apply_physics(frame, elapsed, elapsed)` (accumulated velocity still moves the object — free-fall/knockback with no anim). acdream `Advance` with no `_currNode` does nothing. | `0x00525b80` (line 302402, §22) | `AnimationSequencer.cs:874` (`while (... && _currNode != null ...)` — falls out) | MED |
| G9 | **`advance_to_next_animation`'s four-pose-op transition + reverse node stepping + asymmetric wrap.** Retail per node transition: (a) subtract outgoing node's pos_frame at current frame_number + apply_physics(1/framerate, sign=elapsed); (b) step node — forward: `GetNext` else wrap to **first_cyclic**; reverse: `GetPrev` else wrap to **LIST TAIL**; (c) `frame_number = get_starting_frame()` (fwd) / `get_ending_frame()` (rev); (d) combine incoming node's pos_frame + apply_physics. acdream `AdvanceToNextAnimation` only steps `.Next`/wraps to `_firstCyclic`/holds-on-last (invented hold), resets `_framePosition`, and does **none** of the pose subtract/combine ops and has **no reverse branch at all**. | `0x005252b0` (line 301622, §23) | `AnimationSequencer.cs:1344-1364` | **HIGH** (fwd pose ops + reverse branch); the hold-on-last-node is an acdream invention to delete |

View file

@ -263,11 +263,11 @@ What R2 ships and where R3 plugs in:
CSequence.UpdateInternal [R1, shipped]
└─ G5 gate (head != first_cyclic) → IAnimHookQueue.AddAnimDoneHook
└─ AdapterHookQueue → AnimationDoneSentinel into _pendingHooks [R1-P5]
GameWindow anim tick (:9876-9890) [R2-Q4 wiring]
├─ per drained AnimationDoneSentinel → manager.AnimationDone(success: true)
GameWindow anim tick (:9876-9890) [R2-Q4 wiring; superseded by R6]
├─ per captured AnimationDoneSentinel → manager.AnimationDone(success: true)
│ [retail: AnimDoneHook::Execute 0x00526c20 → Hook_AnimDone 0x0050fda0
│ → CPartArray::AnimationDone(1) — one call per queued hook]
└─ once per tick → manager.UseTime() ≡ CheckForCompletedMotions()
└─ later per-object manager tail → manager.UseTime() ≡ CheckForCompletedMotions()
[retail call sites 0x00517d57/0x00517d67; drains zero-tick entries:
stops-without-links, fast-path re-speeds (outTicks=0), truncated nodes]
MotionTableManager.AnimationDone / CheckForCompletedMotions [R2-Q3]
@ -288,10 +288,11 @@ completion chain, never a peer** — retail keeps TWO pending trackers
(`MotionTableManager.pending_animations` under CPartArray vs
`CMotionInterp.pending_motions` under MovementManager) and `CPhysicsObj::MotionDone`
feeds only the interp side; do not merge the queues. And the per-tick PLACEMENT of
both the sentinel drain and UseTime is provisional until **R6** installs retail's
`UpdateObjectInternal` order (process_hooks LAST; MovementManager.UseTime/
CPartArray.HandleMovement mid-tick) — R2 documents the current GameWindow drain point
as the G6 seam, unchanged.
both the sentinel drain and UseTime was provisional until **R6** installed retail's
`UpdateObjectInternal` order. Named-retail correction (2026-07-19): `process_hooks`
runs inside `UpdatePositionInternal`, before the transition and the manager tail;
`MovementManager.UseTime` then precedes `CPartArray.HandleMovement`. See
`docs/research/2026-07-19-r6-update-object-order-pseudocode.md`.
Success-flag semantics to preserve: AnimationDone passes the CALLER's flag (true from
Hook_AnimDone; false from enter/exit-world drains); CheckForCompletedMotions hardcodes

View file

@ -0,0 +1,215 @@
# 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:
- **control hooks** such as `AnimDoneHook`, whose result changes the motion
queues observed later in the same object update; and
- **pose-dependent presentation hooks** such as particles and sounds, whose
App-layer delivery may wait until the final root/part pose has been
published without changing motion state.
That split is an acdream presentation boundary. It does not change retail's
logical hook order.
## Named-retail sources
- `CPhysicsObj::process_hooks` `0x00511550`
- `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
```
## Pseudocode
### `CPhysicsObj::update_object`
```text
if parent != null or cell == null or state contains Frozen:
return
refresh active/view-distance state
elapsed = current_physics_time - last_update_time
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 `>=`. A sub-minimum remainder stays in
the clock for a later call.
### `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
execute each queued physics hook in FIFO order
clear physics-hook queue
execute each queued animation hook in FIFO order
clear animation-hook 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. A missing manager is simply skipped. No manager is reordered around
another manager based on local/remote/player/NPC classification.
## 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. Pose-dependent routing remains deferred until
the current frame's root/part poses are published. 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. Hidden objects skip sequence advance but retain the same manager tail.
5. A visual hook sees a pose published after capture, while an `AnimDoneHook`
changes motion state at capture time.
6. Deferred visual-hook drain never completes an animation twice and never
performs a zero-tick motion sweep.
7. A sub-MinQuantum frame performs no object-tail call; accumulated time is
consumed by a later quantum.
## Cross-reference notes
ACE's physics/motion ports remain interpretation aids for manager ownership
and state naming; the retail binary above is authoritative for order. The
existing `RemoteChaseEndToEndHarnessTests` hand-codes acdream's pre-R6 order
and is therefore a migration target, not an oracle.