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

@ -216,7 +216,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
| AP-117 | Outdoor particle `CLandCell::IsInView` state is reconstructed with the modern landscape renderer's per-cell frustum plus active doorway clip-plane/scissor-AABB tests; retail `LScape::landcell_check` uses `Render::get_clip_height` + `Render::block_check` on terrain-cell corner intervals | `src/AcDream.App/Rendering/TerrainModernRenderer.cs` (`CollectVisibleCells`) | The mandatory modern renderer batches terrain by landblock and has no retail `ViewIntervalType` product. Publishing cell visibility from the exact landscape draw slices preserves ownership/order and removes the former object-survivor dependency without adding a second view pipeline | At a terrain cell grazing a frustum or doorway boundary, the conservative AABB test may freeze or resume particles on a slightly different frame than retail; whole regions outside the active doorway slice are rejected, and authored distance, login/portal fail-closed behavior, and indoor PView cells remain exact | `LScape::landcell_check @ 0x005050A0`; `CLandCell::IsInView @ 0x00532CB0`; `CPhysicsObj::ShouldDrawParticles @ 0x0050FE60` |
| AP-118 | An AutoWield transaction begun in active combat preserves the ready mode implied by the requested weapon. After authoritative `WieldObject`, a mode that settled without a blocker transition clears immediately; local ACE's observed pre-wield transition plus `ready -> NonCombat`, or post-wield `NonCombat -> ready -> NonCombat`, causes one normal `ChangeCombatMode` request from the trailing notice. Explicit user combat input cancels settlement. Retail's client does not need this extra request against the retail server. | `src/AcDream.App/UI/AutoWieldController.cs`; production binding in `GameWindow.cs` | Local ACE queues a trailing NonCombat callback during primary-weapon replacement and rejects an earlier request while the shuffle is busy; responding to the authoritative notice that completes that exact sequence orders the ordinary request after it without suppressing any server state | A non-ACE server that emits a different intermediate sequence can retain the settlement until a later explicit combat request, replacement, or logout clears it; peace-mode equips send none | `CPlayerSystem::AutoWield @ 0x00560A60`; `ACCWeenieObject::ServerSaysMoveItem @ 0x0058DBB0`; ACE `Player_Inventory.TryShuffleStance` / `TryDequipObjectWithNetworking` |
## 4. Temporary stopgap (TS) — 33 active rows (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-44 narrowed same day — NPC UP unified onto the interp queue, gate retained for orientation)
## 4. Temporary stopgap (TS) — 32 active rows (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-42 retired 2026-07-19 — semantic animation completion now precedes the ordered Target/Movement/PartArray/Position tail; TS-44 narrowed 2026-07-07 — NPC UP unified onto the interp queue, gate retained for orientation)
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|---|---|---|---|---|---|
@ -249,7 +249,6 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
| TS-37 | RETIRED misattribution note (not a live divergence — kept here as the historical record R3-W3 closes): the S2a port had `contact_allows_move` (0x00528240) arm `StandingLongJump` as a side effect, explicitly flagged "PRE-EXISTING acdream side effect (not part of 0x00528240)". R3-W3 deletes that side effect; `ChargeJump` (0x005281c0) is now the ONLY arming site, matching retail exactly. No further action — recorded per the register's retire-in-same-commit rule | `src/AcDream.Core/Physics/MotionInterpreter.cs` (`contact_allows_move`, `ChargeJump`) | N/A — retired | N/A — retired | `CMotionInterp::charge_jump` 0x005281c0 @305448 |
| TS-38 | `MotionInterpreter.Initted` defaults to `true` in both constructors, not retail's `false` — retail's `CMotionInterp` is never observed pre-`enter_default_state` (every real construction path calls it before exposing the interpreter); acdream's constructors are used directly by ~40 pre-existing tests and both App call sites as complete, immediately-usable objects with no separate "enter default state" step | `src/AcDream.Core/Physics/MotionInterpreter.cs` (`Initted` property + both constructors) | Defaulting `true` is the C# equivalent of "the constructor already did what `enter_default_state` would have done to this flag" — `EnterDefaultState()` remains available, verbatim, for the REST of retail's reset semantics (state defaults, sentinel enqueue, `LeaveGround` tail) when a caller wants them | None observed: no code path needs `apply_current_movement`/`ReportExhaustion` to no-op before an explicit `EnterDefaultState()` call, since nothing constructs a `MotionInterpreter` and defers initialization today. If a future caller DOES need staged construction (build now, `EnterDefaultState()` later), it must explicitly set `Initted = false` first | `CMotionInterp::enter_default_state` 0x00528c80 @306124 sets `initted = 1`; retire if/when construction is staged through `EnterDefaultState()` uniformly |
| ~~TS-41~~ | **RETIRED 2026-07-07 (remote-creature de-overlap #184)** — the SERVERVEL synth-velocity body-drive (`Body.Velocity = ServerVelocity` / `get_state_velocity()` leg) is DELETED. Grounded NPC remotes now translate by the retail interp CATCH-UP (`RemoteMotionCombiner.ComputeOffset``InterpolationManager::adjust_offset` toward the MoveOrTeleport-queued server waypoint) and `MovementManager::UseTime` (`TickRemoteMoveTo`) runs UNCONDITIONALLY per tick — the retail `UpdateObjectInternal` shape (no wire-velocity leg-driver). The de-overlap sweep resolves the catch-up movement; the resolved position is written back into the SHADOW (AP-86) so it persists. Residual: the non-retail anim-cycle stale-stop heuristic (`ApplyServerControlledVelocityCycle(Zero)` on a >0.6 s velocity-staleness timer) is kept as ANIM-only and stays covered by **AP-80**; it no longer drives the body. | `src/AcDream.App/Rendering/GameWindow.cs` (`TickAnimations` grounded NPC branch) | — | — | `CPhysicsObj::UpdateObjectInternal` 0x005156b0 (`MovementManager::UseTime` @0x00515998, unconditional); `MoveOrTeleport` 0x00516330; `InterpolationManager::adjust_offset` 0x00555d30 |
| TS-42 | Per-tick DRAIN ORDER inverted vs retail: acdream's `TickAnimations` runs `HandleTargetting``Movement.UseTime` (the R5-V5 MovementManager relay, ex-loose `MoveTo.UseTime`) FIRST and the animation-completion drain (Sequencer.Advance → AnimDone hooks → `MotionTableManager.AnimationDone`/`UseTime``CMotionInterp.MotionDone` pops) LAST, so every motion-completion-gated decision (`BeginTurnToHeading`'s `motions_pending` wait) sees a queue that is one frame STALE — the unblock after a stop/swing lands one frame later than retail. Retail order (pinned from the named decomp this session): `UpdatePositionInternal` (CPartArray::Update + `process_hooks` @0x00512d3d — the drain) runs BEFORE `TargetManager::HandleTargetting` @0x00515989`MovementManager::UseTime` @0x00515998`CPartArray::HandleMovement` @0x005159a4 (zero-tick sweep) in `UpdateObjectInternal` | `src/AcDream.App/Rendering/GameWindow.cs` (`TickAnimations` per-entity phase order; the R2-Q4 comment already marks the placement "provisional until R6") | Bounded to exactly ONE frame (~16 ms) of extra latency per completion-gated event; every queue eventually drains identically (RemoteChaseEndToEndHarnessTests conformance) | Motion-completion-gated transitions (chase turn start, post-swing re-arm) systematically lag retail by one frame; under compound churn the lag can cost an extra retry cycle | `CPhysicsObj::UpdateObjectInternal` 0x005156b0 + `UpdatePositionInternal` 0x00512c30 (`process_hooks` @0x00512d3d); retire in R6 (retail UpdateObjectInternal order) |
| TS-44 | NPC UpdatePosition reconciliation (position + orientation) is SUPPRESSED while the entity is stuck (`PositionManager.GetStickyObjectId() != 0`). **#184 (2026-07-07) UNIFIED the NPC path onto the interp queue** — the retire-condition mechanism is now ported: the grounded UP routes through `CPhysicsObj::MoveOrTeleport` (Enqueue) and the per-tick catch-up SEEDS the `PositionManager::adjust_offset` chain where `StickyManager::adjust_offset` OVERWRITES it while armed (0x00555190 / 0x00555430 assigns m_fOrigin), exactly like the player-remote branch — so POSITION is now handled retail-faithfully by the compose-overwrite whether the gate is on or off. The `snapSuppressedByStick` gate is RETAINED for two residuals: (a) it suppresses the during-stick **Enqueue** so a stale server waypoint never enters the queue, and (b) it gates the **orientation** hard-snap (still outside the interp chain — retail's sticky owns facing). Bookkeeping (`LastServerPos/Time`, cell) still records; server truth reasserts on the first UP after unstick | `src/AcDream.App/Rendering/GameWindow.cs` (`OnLivePositionUpdated` NPC section, `snapSuppressedByStick` gate) | Retail's position mechanism (sticky-overwrites-interp) is now REACHABLE for NPCs (#184 gave them the interp-queue architecture); the gate remains only for orientation + during-stick Enqueue cleanliness | A stick that stays armed while ACE moves the monster far (shouldn't happen — sticks follow the target) keeps a stale orientation until unstick+next UP; bounded by the 1 s lease | `PositionManager::adjust_offset` 0x00555190; `CPhysicsObj::MoveOrTeleport` 0x00516330 (NPC UP routing, #184); retire the orientation residual in R6 |
| TS-46 | Player/remote collision spheres are passed as TWO SCALARS (radius, capsule-top height) and reconstructed by `SpherePath.InitPath` (foot center at `radius`, head center at `height radius`) — retail passes the Setup's SPHERE LIST verbatim (`CPhysicsObj::transition` 0x00512dc0 → `init_sphere(GetNumSphere, GetSphere, m_scale)`, ≤2 spheres, each origin AND radius × m_scale). With the corrected callers (0.48, 1.835 = Setup.Height) the reconstruction sits 5 mm off the dat: foot center 0.480 vs dat 0.475, head center 1.355 vs dat 1.350 (human Setup 0x02000001). **#184 Slice 3 (2026-07-07) NARROWED this: the remote de-overlap sweep now derives its scalars from the creature's OWN Setup (`GetSetupCylinder` = `setup.Radius`/`setup.Height` × ObjScale) — remotes NO LONGER use human dims regardless of Setup/scale.** RESIDUAL: (a) it is still the two-SCALAR reconstruction, not retail's ≤2-sphere LIST (lossy for creatures whose foot/head spheres differ), for both player and remotes; (b) the remote sweep's `stepUpHeight`/`stepDownHeight` stay a hardcoded 0.4 m, where retail derives them from `setup->step_up_height`/`step_down_height` (0x005180d0/0x005180f0, 0.04 m fallback, `radius×0.5` clamp) — an adjacent non-Setup divergence left for a later slice. (The pre-2026-07-06 value 1.2f put the head TOP at 1.2 m — the #137 window climb; fixed same day.) | `src/AcDream.Core/Physics/TransitionTypes.cs` (`InitPath`); `src/AcDream.App/Input/PlayerMovementController.cs` (player, human — correct as-is); `src/AcDream.App/Rendering/GameWindow.cs` (remote sweep now `GetSetupCylinder`-fed with a shapeless→human fallback) | The scalar API predates the Setup ingestion; 5 mm is below the visual/feel threshold; the remote scalars are now the creature's real (radius,height)×ObjScale, consistent with the shadow registration's entScale and the moveto/sticky radii | Marginal rε/r+ε grazes still flip on the 5 mm scalar offset; a creature whose head sphere is wider than its foot de-overlaps by the single (radius) approximation, not the true 2-sphere profile; the 0.4 m step heights are non-Setup for all movers | `CPhysicsObj::transition` 0x00512dc0; `SPHEREPATH::init_sphere` 0x0050c670 (≤2, ×m_scale); `set_description` 0x00514f40 (m_scale from wire ObjScale); retire by plumbing the full Setup sphere list into `InitPath` |
| ~~TS-45~~ | **RETIRED 2026-07-07** — the hand-rolled `SphereCollision` (forced `combinedR+1 cm` radial de-penetration + leaked `SetSlidingNormal` + always-Slid, head-sphere ignored) is REPLACED by the faithful `CSphere::intersects_sphere` family port (branch dispatcher 0x00537A80 + `step_sphere_up`/`slide_sphere`/`land_on_sphere`/`collide_with_point`/`step_sphere_down`), routing the grounded slide through the shared crease `SlideSphere` (0x00537440). Humanoid creatures collide via body Spheres, so this was the player-vs-monster crowd path; the radial de-penetration was the "can't wiggle free in a packed crowd" wedge. `SphereCollisionFamilyTests` (slide-around, block, ethereal) + `docs/research/2026-07-07-csphere-collision-family-pseudocode.md`. Residual `AP-84` (PerfectClip TOI dead in M1.5). | — | — | — | `CSphere::intersects_sphere` 0x00537A80 (pc:321678) |

View file

@ -871,7 +871,10 @@ diagnostic scaffolding, not yet the final collision system.
zero behavior change; 15 facade conformance tests; suite 4052 green with the
183-case/funnel/moveto/chase/sticky suites unmodified. Arc close-out:
`docs/research/2026-07-03-r5-managers/r5-wiring-handoff.md`. Open follow-ons
carried: #167 (ConstraintManager arming), TS-42 per-tick order (R6).
carried: #167 (ConstraintManager arming). TS-42's per-tick completion/
manager order retired in the first R6 cutover on 2026-07-19; complete-frame
plumbing, PositionManager interpolation, quantum ownership, and side-channel
deletion remain R6 scope.
- **L.2g — Inbound motion interpretation (remote-entity CMotionInterp funnel).**
ACTIVE 2026-07-02. Port retail's inbound motion pipeline verbatim for ALL
remote entities (players, NPCs, monsters — one funnel, user-approved):

View file

@ -12,7 +12,8 @@ render/resource-lifetime integration passed its seven-destination connected
stress gate without a crash or cumulative collapse. The remaining M3 visual
requirement is the final two-client portal-out/materialize observer gate.
Carried:
#145-residual, #116 slide-response, R6/TS-42, and Track MP0.
#145-residual, #116 slide-response, the remaining R6 object-pipeline cutover
(TS-42 manager order retired 2026-07-19), and Track MP0.
---

View file

@ -48,8 +48,11 @@ CPhysicsObj (local player, remote player, NPC, monster — ONE pipeline)
└─ per-tick UpdateObjectInternal 0x005156b0 order:
CPartArray.Update (CSequence root motion) → PositionManager.adjust_offset
(chase REPLACES) → Frame.combine → UpdatePhysicsInternal → FULL transition
sweep (ALL entities — retires the remote no-sweep fork) → MovementManager.UseTime
/ CPartArray.HandleMovement / PositionManager.UseTime → process_hooks
sweep (ALL entities — retires the remote no-sweep fork) → DetectionManager /
TargetManager / MovementManager.UseTime / CPartArray.HandleMovement /
PositionManager.UseTime. `process_hooks` is inside UpdatePositionInternal,
before the sweep and manager tail (corrected 2026-07-19 from the named
retail body; see `docs/research/2026-07-19-r6-update-object-order-pseudocode.md`).
```
## Already-verbatim components (absorbed, not rewritten)

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.

View file

@ -1126,7 +1126,7 @@ public sealed class PlayerMovementController
/// live. A composed offset still commits through CTransition so cell
/// membership and contact state cannot become stale behind the hidden mesh.
/// </summary>
public MovementResult TickHidden(float dt)
public MovementResult TickHidden(float dt, Action? handleTargeting = null)
{
_simTimeSeconds += dt;
_physicsAccum = 0f;
@ -1180,8 +1180,14 @@ public sealed class PlayerMovementController
UpdateCellId(resolved.CellId, "hidden-position-manager");
}
Movement.UseTime();
PositionManager?.UseTime();
RetailObjectManagerTail.Run(
checkDetection: null,
handleTargeting,
movementUseTime: Movement.UseTime,
partArrayHandleMovement: _motion.CheckForCompletedMotions,
positionUseTime: PositionManager is { } positionManager
? positionManager.UseTime
: null);
_prevPhysicsPos = _body.Position;
_currPhysicsPos = _body.Position;
_wasAirborneLastFrame = !_body.OnWalkable;
@ -1201,42 +1207,13 @@ public sealed class PlayerMovementController
CurrentStyle: _motion.RawState.CurrentStyle);
}
public MovementResult Update(float dt, MovementInput input)
public MovementResult Update(
float dt,
MovementInput input,
Action? handleTargeting = null)
{
_simTimeSeconds += dt;
// R4-V5: tick the verbatim MoveToManager at the slot the deleted
// DriveServerAutoWalk occupied — after inbound wire routing, before
// the input-driven motion sections. UseTime runs the retail per-tick
// moveto drivers (HandleMoveToPosition aux-turn steering + arrival +
// fail-distance / HandleTurnToHeading); the motions it dispatches
// land in InterpretedState through _DoMotion → DoInterpretedMotion,
// and sections 1/2 below turn that state into Yaw rotation + body
// velocity — the same per-frame apply user input gets. No
// synthesized input exists, so the outbound-packet pipeline sees no
// user motion and never builds a MoveToState mid-moveto (the #75
// invariant, now by construction). Provisional tick placement until
// R6 ports retail's UpdateObjectInternal ordering (r4-port-plan.md
// §3 placement decision). R5-V5: the facade relay
// (MovementManager::UseTime 0x005242f0 — moveto side only).
Movement.UseTime();
// R4-V5 wedge fix (2026-07-03 live door bug), retail's per-tick
// completion slot: CPartArray::HandleMovement — a tailcall chain to
// MotionTableManager::CheckForCompletedMotions (0x00517d60 →
// 0x0051bfd0) — runs every tick right AFTER MovementManager::UseTime
// in UpdateObjectInternal (@005159a4). It flushes pending_animations
// entries whose animations already ran out so their AnimationDone →
// MotionDone pops land each tick even when no op fired that frame.
// (apply_current_movement is NOT part of the per-tick order — its
// retail callers are all event-driven: hold-key toggles, HitGround/
// LeaveGround, ReportExhaustion.) Full tick ordering remains R6
// scope; the companion fix is StopCompletely's previously-missing
// StopCompletely_Internal animation dispatch (see
// MotionInterpreter.StopCompletely), whose completable entry this
// slot drains.
_motion.CheckForCompletedMotions?.Invoke();
// Portal-space guard: while teleporting, no input is processed and
// no physics is resolved. Return a zero-movement result so the caller
// can detect the frozen state (MotionStateChanged = false, no commands).
@ -1710,13 +1687,6 @@ public sealed class PlayerMovementController
{
_prevPhysicsPos = oldTickEndPos;
_currPhysicsPos = _body.Position;
// R5-V3 (#171): retail UpdateObjectInternal TAIL — PositionManager::
// UseTime (@0x005159b3) runs AFTER the quantum's integration
// (whose head hosts adjust_offset) and only on frames where a
// physics quantum executed (retail's update_object MinQuantum
// gate skips the whole UpdateObjectInternal).
PositionManager?.UseTime();
}
// SetPositionInternal contact determination (pc:283468-510). acdream's resolver
@ -1778,6 +1748,22 @@ public sealed class PlayerMovementController
_wasAirborneLastFrame = !_body.OnWalkable;
UpdateCellId(resolveResult.CellId, "resolver");
// Named retail CPhysicsObj::UpdateObjectInternal (0x005156B0):
// process_hooks has already completed inside UpdatePositionInternal,
// then the transition commits, and only then does the ordered manager
// tail run. DetectionManager is not ported yet, so its slot is absent.
// PositionManager's clock stays tied to a consumed physics quantum;
// the remaining per-render versus per-quantum unification belongs to
// the R6 object scheduler rather than this ordering cutover.
RetailObjectManagerTail.Run(
checkDetection: null,
handleTargeting,
movementUseTime: Movement.UseTime,
partArrayHandleMovement: _motion.CheckForCompletedMotions,
positionUseTime: physicsTickRan && PositionManager is { } positionManager
? positionManager.UseTime
: null);
// ── 6. Determine outbound motion commands ─────────────────────────────
uint? outForwardCmd = null;
float? outForwardSpeed = null;

View file

@ -76,12 +76,11 @@ public sealed class RetailLocalPlayerFrameController
return;
controller.LocalEntityId = _resolveLocalEntityId();
_handleTargeting();
bool hidden = _isHidden();
MovementResult movement = hidden
? controller.TickHidden(deltaSeconds)
: controller.Update(deltaSeconds, _captureInput());
? controller.TickHidden(deltaSeconds, _handleTargeting)
: controller.Update(deltaSeconds, _captureInput(), _handleTargeting);
_project(controller, movement, hidden);
_sendPreNetwork(controller, movement, hidden);

View file

@ -84,7 +84,11 @@ internal sealed class RemotePhysicsUpdater
|| record.WorldEntity is not { } entity)
continue;
TickHidden(remote, entity, dt);
System.Action? handlePartArray = record.AnimationRuntime is AnimatedEntity
{ Sequencer: { } sequencer }
? sequencer.Manager.UseTime
: null;
TickHidden(remote, entity, dt, handlePartArray);
// PositionManager callbacks can rebucket, delete, or replace this
// GUID. Publish only while the captured record/runtime pair still
// owns a loaded spatial projection.
@ -116,16 +120,6 @@ internal sealed class RemotePhysicsUpdater
int liveCenterY)
{
uint serverGuid = ae.Entity.ServerGuid;
// R5-V2: retail UpdateObjectInternal ticks TargetManager::
// HandleTargetting UNCONDITIONALLY per entity, BEFORE the
// movement managers' UseTime. This is where this entity, as a
// watched target, pushes its position to its voyeurs (any entity
// moving-to it), and where its own target-info staleness times
// out. Runs for every remote regardless of the grounded/airborne
// branch below (which drive MoveToManager.UseTime via
// TickRemoteMoveTo). No-op for entities with no target + no voyeurs.
rm.Host?.HandleTargetting();
// #184 Slice 2b — the UNIFIED per-remote tick. The former Path A
// (grounded PLAYER remotes: interp catch-up with the ResolveWithTransition
// sweep OMITTED, per the now-retired issue-#40 "collision is the sender's
@ -221,7 +215,6 @@ internal sealed class RemotePhysicsUpdater
// the sink (the LEGS). Position comes from the catch-up; legs from
// this per-node dispatch + the funnel. The #170-deleted per-frame
// apply_current_movement is NOT reintroduced.
TickRemoteMoveTo(rm);
}
else
{
@ -609,7 +602,21 @@ internal sealed class RemotePhysicsUpdater
// sticky 1 s lease watchdog (StickyManager::UseTime
// 0x00555610 — a stick not re-issued by a fresh server arm
// within 1 s tears itself down). No-op while nothing is stuck.
rm.Host?.PositionManager.UseTime();
System.Action? handleTargeting = rm.Host is { } targetHost
? targetHost.HandleTargetting
: null;
System.Action? partArrayHandleMovement = ae.Sequencer is { } sequencer
? sequencer.Manager.UseTime
: null;
System.Action? positionUseTime = rm.Host is { } positionHost
? positionHost.PositionManager.UseTime
: null;
AcDream.Core.Physics.RetailObjectManagerTail.Run(
checkDetection: null,
handleTargeting,
movementUseTime: rm.Movement.UseTime,
partArrayHandleMovement,
positionUseTime);
}
/// <summary>
@ -624,7 +631,8 @@ internal sealed class RemotePhysicsUpdater
public void TickHidden(
RemoteMotion rm,
AcDream.Core.World.WorldEntity entity,
float dt)
float dt,
System.Action? partArrayHandleMovement = null)
{
ArgumentNullException.ThrowIfNull(rm);
ArgumentNullException.ThrowIfNull(entity);
@ -700,20 +708,18 @@ internal sealed class RemotePhysicsUpdater
entity.ParentCellId = rm.CellId;
entity.Rotation = rm.Body.Orientation;
rm.Host?.HandleTargetting();
TickRemoteMoveTo(rm);
rm.Host?.PositionManager.UseTime();
}
/// <summary>
/// R4-V5 / R5-V2: the per-tick <see cref="AcDream.Core.Physics.Motion.MovementManager"/>
/// drive (retail <c>MovementManager::UseTime</c> 0x005242f0 — the moveto
/// side's steering, arrival, fail-distance; R5-V5 facade relay). Moved from
/// GameWindow (#184 Slice 2a); the DR tick is its only caller.
/// </summary>
private static void TickRemoteMoveTo(RemoteMotion rm)
{
rm.Movement.UseTime();
System.Action? handleTargeting = rm.Host is { } targetHost
? targetHost.HandleTargetting
: null;
System.Action? positionUseTime = rm.Host is { } positionHost
? positionHost.PositionManager.UseTime
: null;
AcDream.Core.Physics.RetailObjectManagerTail.Run(
checkDetection: null,
handleTargeting,
movementUseTime: rm.Movement.UseTime,
partArrayHandleMovement,
positionUseTime);
}
/// <summary>

View file

@ -11113,6 +11113,7 @@ public sealed class GameWindow : IDisposable
var ae = kv.Value;
bool sequenceAdvancedBeforeAnimationPass =
ae.SequenceAdvancedBeforeAnimationPass;
bool managerTailHandledForObject = ae.Entity.ServerGuid == _playerServerGuid;
IReadOnlyList<AcDream.Core.Physics.PartTransform>? preparedSequenceFrames =
ae.PreparedSequenceFrames;
ae.SequenceAdvancedBeforeAnimationPass = false;
@ -11208,6 +11209,7 @@ public sealed class GameWindow : IDisposable
remoteRootMotionFrame.Origin,
_liveCenterX,
_liveCenterY);
managerTailHandledForObject = true;
}
// ── Get per-part (origin, orientation) from either sequencer or legacy ──
@ -11264,7 +11266,9 @@ public sealed class GameWindow : IDisposable
// This matches CPhysicsObj::UpdateObjectInternal followed by
// CPhysicsObj::UpdateChild (0x00512D50): a hand/weapon effect
// reads this frame's pose, never the previous frame's pose.
// AnimationDone/UseTime remain paired with the deferred drain.
// AnimationDone is processed semantically by Capture at
// retail's process_hooks slot. Pose-dependent delivery is
// deferred; the later manager-tail block owns UseTime.
if (!hidden)
_animationHookFrames?.Capture(ae.Entity.Id, ae.Sequencer);
}
@ -11287,6 +11291,41 @@ public sealed class GameWindow : IDisposable
}
}
// R6: objects which did not enter the local or moving-remote
// physics owner still need their CPartArray manager tail. Doors
// and levers commonly receive UpdateMotion without ever receiving
// UpdatePosition; classifying the tail by LastServerPosTime made
// their zero-tick completions depend on the old global hook drain.
// Hidden remotes were already handled by TickHiddenEntities above.
bool hiddenRemoteManagerTailHandled = hidden
&& _remoteDeadReckon.TryGetValue(serverGuid, out _);
AcDream.Core.Physics.AnimationSequencer? tailSequencer = ae.Sequencer;
if (!managerTailHandledForObject
&& !hiddenRemoteManagerTailHandled
&& tailSequencer is not null)
{
if (_remoteDeadReckon.TryGetValue(serverGuid, out var tailRemote)
&& _projectileController?.HandlesMovement(serverGuid) != true)
{
System.Action? handleTargeting = tailRemote.Host is { } targetHost
? targetHost.HandleTargetting
: null;
System.Action? positionUseTime = tailRemote.Host is { } positionHost
? positionHost.PositionManager.UseTime
: null;
AcDream.Core.Physics.RetailObjectManagerTail.Run(
checkDetection: null,
handleTargeting,
movementUseTime: tailRemote.Movement.UseTime,
partArrayHandleMovement: tailSequencer.Manager.UseTime,
positionUseTime);
}
else
{
tailSequencer.Manager.UseTime();
}
}
int partCount = ae.PartTemplate.Count;
// D5 (Commit A 2026-05-03): PARTSDIAG — proves whether

View file

@ -45,9 +45,22 @@ public sealed class AnimationHookFrameQueue
{
ArgumentNullException.ThrowIfNull(sequencer);
ArgumentNullException.ThrowIfNull(hooks);
// Retail CPhysicsObj::process_hooks (0x00511550) executes AnimDone
// inside UpdatePositionInternal, before the transition and every
// UpdateObjectInternal manager. Complete the semantic motion state at
// capture time so that this object's later Target/Movement/PartArray
// tail sees the new queue in the same quantum. The hook remains in the
// deferred entry so presentation sinks still observe the complete
// authored hook stream after the final part poses are published.
for (int i = 0; i < hooks.Count; i++)
{
if (hooks[i] is AnimationDoneHook)
sequencer.Manager.AnimationDone(success: true);
}
_entries.Add(new Entry(
ownerLocalId,
sequencer,
hooks));
}
@ -69,13 +82,7 @@ public sealed class AnimationHookFrameQueue
continue;
if (hasRootPose)
_router.OnHook(entry.OwnerLocalId, worldPosition, hook);
if (hook is AnimationDoneHook)
entry.Sequencer.Manager.AnimationDone(success: true);
}
// Retail sweeps zero-tick motion entries even on frames without a
// hook. It remains paired with every sequencer advanced this frame.
entry.Sequencer.Manager.UseTime();
}
_entries.Clear();
}
@ -84,6 +91,5 @@ public sealed class AnimationHookFrameQueue
private readonly record struct Entry(
uint OwnerLocalId,
AnimationSequencer Sequencer,
IReadOnlyList<AnimationHook> Hooks);
}

View file

@ -0,0 +1,28 @@
namespace AcDream.Core.Physics;
/// <summary>
/// Executes the manager tail of retail
/// <c>CPhysicsObj::UpdateObjectInternal</c> (<c>0x005156B0</c>).
/// </summary>
/// <remarks>
/// The order is observable. In particular, animation completion hooks have
/// already run when this tail begins, so MoveTo may react to a completion in
/// the same object quantum. A null callback represents an absent retail
/// manager; it is not a separate execution path.
/// </remarks>
public static class RetailObjectManagerTail
{
public static void Run(
Action? checkDetection,
Action? handleTargeting,
Action? movementUseTime,
Action? partArrayHandleMovement,
Action? positionUseTime)
{
checkDetection?.Invoke();
handleTargeting?.Invoke();
movementUseTime?.Invoke();
partArrayHandleMovement?.Invoke();
positionUseTime?.Invoke();
}
}

View file

@ -127,6 +127,29 @@ public sealed class RetailLocalPlayerFrameControllerTests
Assert.Equal(1f / 60f, timeAfterOneTick);
}
[Fact]
public void TargetManager_ObservesPostTransitionPlayerPosition()
{
PlayerMovementController controller = CreateController();
Vector3 initial = controller.Position;
Vector3 positionSeenByTargetManager = new(float.NaN);
var local = new RetailLocalPlayerFrameController(
canPresentPlayer: () => true,
getController: () => controller,
captureInput: () => new MovementInput(Forward: true),
resolveLocalEntityId: () => 7u,
handleTargeting: () => positionSeenByTargetManager = controller.Position,
isHidden: () => false,
project: (_, _, _) => { },
sendPreNetwork: (_, _, _) => { },
sendPostNetwork: (_, _) => { });
local.AdvanceBeforeNetwork(PhysicsBody.MaxQuantum);
Assert.NotEqual(initial, controller.Position);
Assert.Equal(controller.Position, positionSeenByTargetManager);
}
private static PlayerMovementController CreateController()
{
var engine = new PhysicsEngine();

View file

@ -56,6 +56,35 @@ public sealed class RemotePhysicsUpdaterTests
Assert.Equal(entity.Position, root.Translation);
}
[Fact]
public void TickHidden_RunsPartArrayManagerTailWithoutAdvancingSequence()
{
var updater = new RemotePhysicsUpdater(
new PhysicsEngine(),
(_, _) => (0.48f, 1.835f),
(_, _, _, _) => { });
var motion = new GameWindow.RemoteMotion();
motion.Body.Orientation = Quaternion.Identity;
var entity = new WorldEntity
{
Id = 2u,
ServerGuid = 0x70000002u,
SourceGfxObjOrSetupId = 0x02000001u,
Position = Vector3.Zero,
Rotation = Quaternion.Identity,
MeshRefs = Array.Empty<MeshRef>(),
};
int partArrayTailCalls = 0;
updater.TickHidden(
motion,
entity,
0.1f,
() => partArrayTailCalls++);
Assert.Equal(1, partArrayTailCalls);
}
[Fact]
public void TickHidden_CrossCellCommitUpdatesCanonicalCellBeforeUnhide()
{

View file

@ -120,7 +120,7 @@ public sealed class EntityEffectPoseRegistryTests
}
[Fact]
public void AnimationHookQueue_CompletesMotionStateEvenWhenPoseWasRemoved()
public void AnimationHookQueue_CompletesMotionStateAtCaptureEvenWhenPoseWasRemoved()
{
var poses = new EntityEffectPoseRegistry();
var queue = new AnimationHookFrameQueue(new AnimationHookRouter(), poses);
@ -134,12 +134,31 @@ public sealed class EntityEffectPoseRegistryTests
11u,
sequencer,
new AnimationHook[] { new AnimationDoneHook() });
Assert.Empty(sequencer.Manager.PendingAnimations);
queue.Drain();
Assert.Empty(sequencer.Manager.PendingAnimations);
Assert.Equal(0, queue.Count);
}
[Fact]
public void AnimationHookQueue_DeferredDrainDoesNotOwnPartArrayUseTime()
{
var poses = new EntityEffectPoseRegistry();
var queue = new AnimationHookFrameQueue(new AnimationHookRouter(), poses);
var sequencer = new AnimationSequencer(
new Setup(),
new MotionTable(),
new NullAnimationLoader());
sequencer.Manager.AddToQueue(0x10000001u, ticks: 0u);
queue.Capture(12u, sequencer, Array.Empty<AnimationHook>());
queue.Drain();
Assert.Single(sequencer.Manager.PendingAnimations);
}
[Fact]
public void Publish_UsesOutdoorEffectCellWithoutChangingRenderParent()
{

View file

@ -0,0 +1,38 @@
using AcDream.Core.Physics;
namespace AcDream.Core.Tests.Physics;
public sealed class RetailObjectManagerTailTests
{
[Fact]
public void Run_UsesNamedRetailManagerOrder()
{
var calls = new List<string>();
RetailObjectManagerTail.Run(
checkDetection: () => calls.Add("detection"),
handleTargeting: () => calls.Add("target"),
movementUseTime: () => calls.Add("movement"),
partArrayHandleMovement: () => calls.Add("part-array"),
positionUseTime: () => calls.Add("position"));
Assert.Equal(
["detection", "target", "movement", "part-array", "position"],
calls);
}
[Fact]
public void Run_SkipsMissingManagersWithoutDuplicatingNeighbours()
{
var calls = new List<string>();
RetailObjectManagerTail.Run(
checkDetection: null,
handleTargeting: () => calls.Add("target"),
movementUseTime: null,
partArrayHandleMovement: () => calls.Add("part-array"),
positionUseTime: null);
Assert.Equal(["target", "part-array"], calls);
}
}