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
|
|
@ -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) |
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
28
src/AcDream.Core/Physics/RetailObjectManagerTail.cs
Normal file
28
src/AcDream.Core/Physics/RetailObjectManagerTail.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue