diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 92a341b0..b26124da 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -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) | diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index 1bf94451..6c4cf736 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -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): diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md index 627447f3..24890cd4 100644 --- a/docs/plans/2026-05-12-milestones.md +++ b/docs/plans/2026-05-12-milestones.md @@ -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. --- diff --git a/docs/plans/2026-07-02-retail-motion-animation-rewrite.md b/docs/plans/2026-07-02-retail-motion-animation-rewrite.md index a3534d2d..a95bb73e 100644 --- a/docs/plans/2026-07-02-retail-motion-animation-rewrite.md +++ b/docs/plans/2026-07-02-retail-motion-animation-rewrite.md @@ -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) diff --git a/docs/research/2026-07-02-r1-csequence/r1-gap-map.md b/docs/research/2026-07-02-r1-csequence/r1-gap-map.md index 54cc5890..5a4e7d0e 100644 --- a/docs/research/2026-07-02-r1-csequence/r1-gap-map.md +++ b/docs/research/2026-07-02-r1-csequence/r1-gap-map.md @@ -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 | diff --git a/docs/research/2026-07-02-r2-motiontable/r2-port-plan.md b/docs/research/2026-07-02-r2-motiontable/r2-port-plan.md index a7018e89..4737d556 100644 --- a/docs/research/2026-07-02-r2-motiontable/r2-port-plan.md +++ b/docs/research/2026-07-02-r2-motiontable/r2-port-plan.md @@ -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 diff --git a/docs/research/2026-07-19-r6-update-object-order-pseudocode.md b/docs/research/2026-07-19-r6-update-object-order-pseudocode.md new file mode 100644 index 00000000..c4e87243 --- /dev/null +++ b/docs/research/2026-07-19-r6-update-object-order-pseudocode.md @@ -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. diff --git a/src/AcDream.App/Input/PlayerMovementController.cs b/src/AcDream.App/Input/PlayerMovementController.cs index 72a35fee..d97c72f8 100644 --- a/src/AcDream.App/Input/PlayerMovementController.cs +++ b/src/AcDream.App/Input/PlayerMovementController.cs @@ -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. /// - 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; diff --git a/src/AcDream.App/Input/RetailLocalPlayerFrameController.cs b/src/AcDream.App/Input/RetailLocalPlayerFrameController.cs index cf0e393c..d6077509 100644 --- a/src/AcDream.App/Input/RetailLocalPlayerFrameController.cs +++ b/src/AcDream.App/Input/RetailLocalPlayerFrameController.cs @@ -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); diff --git a/src/AcDream.App/Physics/RemotePhysicsUpdater.cs b/src/AcDream.App/Physics/RemotePhysicsUpdater.cs index 0850fd6e..cb594153 100644 --- a/src/AcDream.App/Physics/RemotePhysicsUpdater.cs +++ b/src/AcDream.App/Physics/RemotePhysicsUpdater.cs @@ -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); } /// @@ -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(); - } - - /// - /// R4-V5 / R5-V2: the per-tick - /// drive (retail MovementManager::UseTime 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. - /// - 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); } /// diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 6db9e4b4..8ab6d72a 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -11113,6 +11113,7 @@ public sealed class GameWindow : IDisposable var ae = kv.Value; bool sequenceAdvancedBeforeAnimationPass = ae.SequenceAdvancedBeforeAnimationPass; + bool managerTailHandledForObject = ae.Entity.ServerGuid == _playerServerGuid; IReadOnlyList? 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 diff --git a/src/AcDream.App/Rendering/Vfx/AnimationHookFrameQueue.cs b/src/AcDream.App/Rendering/Vfx/AnimationHookFrameQueue.cs index 3323627b..d729d2a8 100644 --- a/src/AcDream.App/Rendering/Vfx/AnimationHookFrameQueue.cs +++ b/src/AcDream.App/Rendering/Vfx/AnimationHookFrameQueue.cs @@ -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 Hooks); } diff --git a/src/AcDream.Core/Physics/RetailObjectManagerTail.cs b/src/AcDream.Core/Physics/RetailObjectManagerTail.cs new file mode 100644 index 00000000..17fac1cd --- /dev/null +++ b/src/AcDream.Core/Physics/RetailObjectManagerTail.cs @@ -0,0 +1,28 @@ +namespace AcDream.Core.Physics; + +/// +/// Executes the manager tail of retail +/// CPhysicsObj::UpdateObjectInternal (0x005156B0). +/// +/// +/// 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. +/// +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(); + } +} diff --git a/tests/AcDream.App.Tests/Input/RetailLocalPlayerFrameControllerTests.cs b/tests/AcDream.App.Tests/Input/RetailLocalPlayerFrameControllerTests.cs index 29929f19..1048d673 100644 --- a/tests/AcDream.App.Tests/Input/RetailLocalPlayerFrameControllerTests.cs +++ b/tests/AcDream.App.Tests/Input/RetailLocalPlayerFrameControllerTests.cs @@ -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(); diff --git a/tests/AcDream.App.Tests/Physics/RemotePhysicsUpdaterTests.cs b/tests/AcDream.App.Tests/Physics/RemotePhysicsUpdaterTests.cs index d199ff70..9c34eac7 100644 --- a/tests/AcDream.App.Tests/Physics/RemotePhysicsUpdaterTests.cs +++ b/tests/AcDream.App.Tests/Physics/RemotePhysicsUpdaterTests.cs @@ -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(), + }; + int partArrayTailCalls = 0; + + updater.TickHidden( + motion, + entity, + 0.1f, + () => partArrayTailCalls++); + + Assert.Equal(1, partArrayTailCalls); + } + [Fact] public void TickHidden_CrossCellCommitUpdatesCanonicalCellBeforeUnhide() { diff --git a/tests/AcDream.App.Tests/Rendering/Vfx/EntityEffectPoseRegistryTests.cs b/tests/AcDream.App.Tests/Rendering/Vfx/EntityEffectPoseRegistryTests.cs index 8fd5ca62..7c7e7990 100644 --- a/tests/AcDream.App.Tests/Rendering/Vfx/EntityEffectPoseRegistryTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Vfx/EntityEffectPoseRegistryTests.cs @@ -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()); + queue.Drain(); + + Assert.Single(sequencer.Manager.PendingAnimations); + } + [Fact] public void Publish_UsesOutdoorEffectCellWithoutChangingRenderParent() { diff --git a/tests/AcDream.Core.Tests/Physics/RetailObjectManagerTailTests.cs b/tests/AcDream.Core.Tests/Physics/RetailObjectManagerTailTests.cs new file mode 100644 index 00000000..d6abe58e --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/RetailObjectManagerTailTests.cs @@ -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(); + + 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(); + + RetailObjectManagerTail.Run( + checkDetection: null, + handleTargeting: () => calls.Add("target"), + movementUseTime: null, + partArrayHandleMovement: () => calls.Add("part-array"), + positionUseTime: null); + + Assert.Equal(["target", "part-array"], calls); + } +}