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:
parent
0f996db747
commit
c5ab99081c
17 changed files with 493 additions and 98 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
215
docs/research/2026-07-19-r6-update-object-order-pseudocode.md
Normal file
215
docs/research/2026-07-19-r6-update-object-order-pseudocode.md
Normal 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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue