feat(physics): R5-V5 — MovementManager facade owns each entity's interp+moveto pair

Structural capstone of the R5 movement-manager arc; zero behavior change.

Retail MovementManager (acclient.h /* 3463 */, 16 bytes / four pointers)
gives every CPhysicsObj ONE owner for its motion_interpreter +
moveto_manager. acdream carried them as loose per-entity objects wired by
hand at three sites. This slice:

- New src/AcDream.Core/Physics/Motion/MovementManager.cs — owns
  MotionInterpreter + lazy MoveToManager (MakeMoveToManager 0x00524000 via
  a MoveToFactory closure, the acdream stand-in for the physics_obj/
  weenie_obj backpointers) + the relays with retail call shapes:
  PerformMovement 0x005240d0 (types 1-5 -> minterp, 6-9 ->
  MakeMoveToManager + moveto, (type-1)>8 -> 0x47), UseTime 0x005242f0
  (moveto only), HitGround 0x00524300 (minterp FIRST then moveto),
  HandleExitWorld 0x00524350 (minterp only), CancelMoveTo 0x005241b0,
  HandleUpdateTarget 0x00524790, IsMovingTo 0x00524260.
- RemoteMotion.Movement + PlayerMovementController.Movement hold the ONE
  facade; Motion/MoveTo become child views so the comment-dense call sites
  read unchanged. The three wiring sites (EnsureRemoteMotionBindings,
  EnterPlayerModeNow, the chase harness — same commit per the mirror rule)
  construct through MoveToFactory + MakeMoveToManager(), preserving the
  pre-facade eager timing (side-effect-free ctor = unobservable either way).
- Relay call sites repointed: both remote landing HitGround pairs + the
  player landing pair, despawn HandleExitWorld, TickRemoteMoveTo + the
  player Update UseTime, RouteServerMoveTo (takes the facade; routes via
  the retail PerformMovement dispatch), InstallSpeculativeTurnToTarget,
  host HandleUpdateTarget/InterruptCurrentMovement closures (retail
  CPhysicsObj::HandleUpdateTarget @0x00512bf0 fan head + the TS-36
  interrupt chain, now the literal facade relays).
- NOT absorbed per the slice spec: unpack_movement stays App
  (RouteServerMoveTo + UM heads; Core.Net types stay out of Core.Physics);
  TS-42 per-tick order untouched (R6); #170/#171 gate-passed machinery
  untouched. PerformMovement's set_active(1) head not re-asserted (spawn
  asserts Active; status quo — no new register row).
- Register: TS-41/TS-42 source wording freshened to the facade shape;
  AD-36 retire note corrected (facade half closed; residue = entities
  that never get a RemoteMotion). No new rows.
- Conformance: 15 new MovementManagerTests pin the dispatch table, lazy
  create, relay targets/order, null tolerance. Suite 4052 green; the
  183-case/funnel/moveto/chase/sticky suites UNMODIFIED (harness
  construction mirrors production, test bodies untouched).

Decomp: docs/research/2026-07-03-r5-managers/r5-movementmanager-decomp.md

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-05 11:12:19 +02:00
parent 6feaab91e3
commit dccd700991
6 changed files with 833 additions and 200 deletions

View file

@ -95,7 +95,7 @@ accepted-divergence entries (#96, #49, #50).
| AD-33 | `CSequence.frame_number` at C# `double` (64-bit); retail is x87 `long double` (80-bit extended) — every frame-boundary comparison ran at extended precision on retail (Phase R1, 2026-07-02) | `src/AcDream.Core/Physics/Motion/CSequence.cs` (`FrameNumber`) | `double` is the widest C# float type; the R1 port removes ACE-style boundary epsilons so comparisons are exact-int against bare boundaries, minimizing ULP sensitivity (ACE's `float` is far worse) | A frame landing within 1 double-ULP of an integer boundary could classify differently than retail's 80-bit compare — sub-frame timing skew at pathological framerate×dt combinations | `acclient.h:30747` (`long double frame_number`) | | AD-33 | `CSequence.frame_number` at C# `double` (64-bit); retail is x87 `long double` (80-bit extended) — every frame-boundary comparison ran at extended precision on retail (Phase R1, 2026-07-02) | `src/AcDream.Core/Physics/Motion/CSequence.cs` (`FrameNumber`) | `double` is the widest C# float type; the R1 port removes ACE-style boundary epsilons so comparisons are exact-int against bare boundaries, minimizing ULP sensitivity (ACE's `float` is far worse) | A frame landing within 1 double-ULP of an integer boundary could classify differently than retail's 80-bit compare — sub-frame timing skew at pathological framerate×dt combinations | `acclient.h:30747` (`long double frame_number`) |
| AD-34 | Retail's intrusive `DLListBase`/`DLListData` lists are managed `LinkedList<T>`s; node identity via `LinkedListNode<>` references (Phase R1 anim list, 2026-07-02; extended R2-Q3 to `MotionTableManager.pending_animations`; extended R4-V2 to `MoveToManager.pending_actions` — whose node type, retail `MoveToManager::MovementNode`, is RENAMED `MoveToNode` to avoid colliding with R2's `Motion/MotionNode.cs` pending_motions node) | `src/AcDream.Core/Physics/Motion/CSequence.cs` (`_animList`); `src/AcDream.Core/Physics/Motion/MotionTableManager.cs` (`_pendingAnimations`); `src/AcDream.Core/Physics/Motion/MoveToManager.cs` (`_pendingActions`) + `MoveToNode.cs` | Same topology + cursor semantics (curr_anim/first_cyclic/tail-anchored scans are node references); unlink/delete becomes `Remove(node)`; conformance tests pin the surgery state tables | Any retail behavior depending on the 4 pointer adjustment or node memory reuse (none observed in the decomp) would diverge; a reader grepping for retail's `MovementNode` name must find it via this row | `acclient.h` DLListBase; `r1-csequence-decomp.md` §0; `r2-motiontable-decomp.md` §11; `r4-moveto-decomp.md` node factories §4a | | AD-34 | Retail's intrusive `DLListBase`/`DLListData` lists are managed `LinkedList<T>`s; node identity via `LinkedListNode<>` references (Phase R1 anim list, 2026-07-02; extended R2-Q3 to `MotionTableManager.pending_animations`; extended R4-V2 to `MoveToManager.pending_actions` — whose node type, retail `MoveToManager::MovementNode`, is RENAMED `MoveToNode` to avoid colliding with R2's `Motion/MotionNode.cs` pending_motions node) | `src/AcDream.Core/Physics/Motion/CSequence.cs` (`_animList`); `src/AcDream.Core/Physics/Motion/MotionTableManager.cs` (`_pendingAnimations`); `src/AcDream.Core/Physics/Motion/MoveToManager.cs` (`_pendingActions`) + `MoveToNode.cs` | Same topology + cursor semantics (curr_anim/first_cyclic/tail-anchored scans are node references); unlink/delete becomes `Remove(node)`; conformance tests pin the surgery state tables | Any retail behavior depending on the 4 pointer adjustment or node memory reuse (none observed in the decomp) would diverge; a reader grepping for retail's `MovementNode` name must find it via this row | `acclient.h` DLListBase; `r1-csequence-decomp.md` §0; `r2-motiontable-decomp.md` §11; `r4-moveto-decomp.md` node factories §4a |
| AD-35 | `MotionTableManager.PerformMovement`'s unhandled-type default case returns the named sentinel `0xFFFFFFFF` (`MotionTableManagerError.NotHandled`); retail's compiled code leaks the `CSequence*` pointer reinterpreted as the return code (BN-confirmed artifact, dead/unreachable — callers gate on type first) (R2-Q3, 2026-07-02) | `src/AcDream.Core/Physics/Motion/MotionTableManager.cs` (`PerformMovement` default case) | No retail caller consults the return value for unhandled types (RawCommand/StopRawCommand/MoveTo\*/TurnTo\* route elsewhere); returning a stable non-zero sentinel preserves the only observable contract (non-zero = not success) without fabricating a pointer-shaped number | If a future port wires a caller that passes unhandled types AND branches on the exact return value, it would see `0xFFFFFFFF` where retail saw an arbitrary pointer — flag at that port | `PerformMovement` 0x0051c0b0 (`r2-motiontable-decomp.md` §11 default-case note) | | AD-35 | `MotionTableManager.PerformMovement`'s unhandled-type default case returns the named sentinel `0xFFFFFFFF` (`MotionTableManagerError.NotHandled`); retail's compiled code leaks the `CSequence*` pointer reinterpreted as the return code (BN-confirmed artifact, dead/unreachable — callers gate on type first) (R2-Q3, 2026-07-02) | `src/AcDream.Core/Physics/Motion/MotionTableManager.cs` (`PerformMovement` default case) | No retail caller consults the return value for unhandled types (RawCommand/StopRawCommand/MoveTo\*/TurnTo\* route elsewhere); returning a stable non-zero sentinel preserves the only observable contract (non-zero = not success) without fabricating a pointer-shaped number | If a future port wires a caller that passes unhandled types AND branches on the exact return value, it would see `0xFFFFFFFF` where retail saw an arbitrary pointer — flag at that port | `PerformMovement` 0x0051c0b0 (`r2-motiontable-decomp.md` §11 default-case note) |
| AD-36 | `IMotionDoneSink.MotionDone` consumed for CREATURE-class entities only: R3-W2 binds the seam to the entity's `MotionInterpreter.MotionDone` (player via `PlayerMovementController.Motion`, remotes via `RemoteMotion.Motion`, resolved at fire time); interp-less entities (doors, statics) keep a diagnostic-recorder-only target — retail gives every CPhysicsObj a MovementManager/CMotionInterp (R2-Q4 seam, narrowed R3-W2, 2026-07-02) | `src/AcDream.App/Rendering/GameWindow.cs` (TickAnimations MotionDoneTarget bind) | Door/static motion (On/Off cycles) completes via the manager queue alone; nothing consumes their MotionDone until R5 gives every entity class the full MovementSystem pipeline | A door behavior depending on pending_motions bookkeeping (none known — doors have no jump/action semantics) would silently no-op | `CPhysicsObj::MotionDone` 0x0050fdb0; retire in R5 (MovementSystem for all classes) | | AD-36 | `IMotionDoneSink.MotionDone` consumed for CREATURE-class entities only: R3-W2 binds the seam to the entity's `MotionInterpreter.MotionDone` (player via `PlayerMovementController.Motion`, remotes via `RemoteMotion.Motion`, resolved at fire time); interp-less entities (statics that never receive a UM/UP and so never get a `RemoteMotion`) keep a diagnostic-recorder-only target — retail gives every CPhysicsObj a MovementManager/CMotionInterp (R2-Q4 seam, narrowed R3-W2, 2026-07-02; R5-V5 gave every `RemoteMotion`/player ONE literal `MovementManager` facade, so the residue is only the no-RemoteMotion class) | `src/AcDream.App/Rendering/GameWindow.cs` (TickAnimations MotionDoneTarget bind) | Motion for entities without a `RemoteMotion` (never UM/UP-touched) completes via the manager queue alone; nothing consumes their MotionDone until every sequencer-owning entity gets a host/`RemoteMotion` (doors DO have one since the R4-V5 door fix — first UM creates it) | An entity behavior depending on pending_motions bookkeeping in that no-RemoteMotion class (none known) would silently no-op | `CPhysicsObj::MotionDone` 0x0050fdb0; retire when every sequencer-owning entity constructs a `RemoteMotion`/host (post-M1.5 entity-class unification; R5-V5 closed the facade half) |
--- ---
@ -221,8 +221,8 @@ accepted-divergence entries (#96, #49, #50).
| TS-35 | `PhysicsBody.IsFullyConstrained` is a stub property (default `false`, never set by any physics code), read by `jump_is_allowed`'s verbatim `IsFullyConstrained` gate (raw 305524-305525) | `src/AcDream.Core/Physics/PhysicsBody.cs` (`IsFullyConstrained`) | R3-W3 needed the read site to port `jump_is_allowed`'s full chain. **R5-V1 CORRECTED the mechanism** (the earlier "per-cell contact-plane / doorway-jamming" guess was WRONG): the write side is the **ConstraintManager server-position rubber-band leash** — armed by `SmartBox::HandleReceivedPosition` on every inbound server position, `IsFullyConstrained` = `max*0.9 < offset`. R5-V1 ported `ConstraintManager` (`src/AcDream.Core/Physics/Motion/ConstraintManager.cs`) but does NOT arm it (no acdream `SmartBox` + two x87 distance constants BN elided) — so this read stays false. Arming = issue #167 | A body retail would consider fully constrained (still rubber-banding toward a server position inside the tight leash) never refuses the jump (0x47) — a jump succeeds mid-rubber-band where retail blocks it. Low practical risk (the leash band is tight + short-lived) | `CPhysicsObj::IsFullyConstrained` 0x0050ec60 → `ConstraintManager::IsFullyConstrained` 0x005560d0; `jump_is_allowed` 0x005282b0; arming `SmartBox::HandleReceivedPosition` 0x00453fd0 (issue #167) | | TS-35 | `PhysicsBody.IsFullyConstrained` is a stub property (default `false`, never set by any physics code), read by `jump_is_allowed`'s verbatim `IsFullyConstrained` gate (raw 305524-305525) | `src/AcDream.Core/Physics/PhysicsBody.cs` (`IsFullyConstrained`) | R3-W3 needed the read site to port `jump_is_allowed`'s full chain. **R5-V1 CORRECTED the mechanism** (the earlier "per-cell contact-plane / doorway-jamming" guess was WRONG): the write side is the **ConstraintManager server-position rubber-band leash** — armed by `SmartBox::HandleReceivedPosition` on every inbound server position, `IsFullyConstrained` = `max*0.9 < offset`. R5-V1 ported `ConstraintManager` (`src/AcDream.Core/Physics/Motion/ConstraintManager.cs`) but does NOT arm it (no acdream `SmartBox` + two x87 distance constants BN elided) — so this read stays false. Arming = issue #167 | A body retail would consider fully constrained (still rubber-banding toward a server position inside the tight leash) never refuses the jump (0x47) — a jump succeeds mid-rubber-band where retail blocks it. Low practical risk (the leash band is tight + short-lived) | `CPhysicsObj::IsFullyConstrained` 0x0050ec60 → `ConstraintManager::IsFullyConstrained` 0x005560d0; `jump_is_allowed` 0x005282b0; arming `SmartBox::HandleReceivedPosition` 0x00453fd0 (issue #167) |
| 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-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-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 | Grounded remote NPCs WITHOUT an armed moveto are body-driven by UP-synthesized server velocity (`HasServerVelocity` → the SERVERVEL per-tick leg: `Body.Velocity = ServerVelocity`, `MoveToManager.UseTime` SKIPPED, stale-decay stop via `ApplyServerControlledVelocityCycle(Zero)`); retail has no wire-velocity leg-driver anywhere — `MovementManager::UseTime` runs UNCONDITIONALLY per tick and between-UP translation comes from the motion state (`get_state_velocity`), UPs only hard-snap. **#170 residual fix narrowed this branch: an ARMED moveto (`MovementTypeState != Invalid`) now always takes the MOVETO leg** — the old arbitration starved the verbatim MoveToManager for exactly the duration of a server-side chase (UPs flowing → UseTime never ran → legs stayed Ready while the body glided = the #170 slide; live funnel 16 arms → 1 run install). **R5-V3 (#171) narrowed it again: a STUCK entity (`PositionManager.GetStickyObjectId() != 0`) also takes the MOVETO leg** — after the sticky arrival the moveto is cleaned (Invalid) but `StickyManager::adjust_offset` owns the between-snap translation; SERVERVEL would glide the body against the sticky steer (same starvation class) | `src/AcDream.App/Rendering/GameWindow.cs` (`TickAnimations` grounded NPC branch, `moveToArmed`+`stickyArmed` gate) | ACE moves some entities by position updates alone (scripted paths, missiles) with no UM/moveto stream — without a velocity fallback they freeze between UPs; entities WITH a moveto now get the retail drive | An entity class that carries BOTH wire velocity and an armed moveto with conflicting truths follows the moveto; UP hard-snaps bound the drift. Non-moveto entities keep the non-retail stale-stop heuristics (AP-80 thresholds) | `CPhysicsObj::UpdateObjectInternal` 0x005156b0 (`MovementManager::UseTime` call @0x00515998, unconditional); retire in R6 (full per-tick order) | | TS-41 | Grounded remote NPCs WITHOUT an armed moveto are body-driven by UP-synthesized server velocity (`HasServerVelocity` → the SERVERVEL per-tick leg: `Body.Velocity = ServerVelocity`, `MovementManager.UseTime` (the R5-V5 facade relay, ex-loose `MoveToManager.UseTime`) SKIPPED, stale-decay stop via `ApplyServerControlledVelocityCycle(Zero)`); retail has no wire-velocity leg-driver anywhere — `MovementManager::UseTime` runs UNCONDITIONALLY per tick and between-UP translation comes from the motion state (`get_state_velocity`), UPs only hard-snap. **#170 residual fix narrowed this branch: an ARMED moveto (`MovementTypeState != Invalid`) now always takes the MOVETO leg** — the old arbitration starved the verbatim MoveToManager for exactly the duration of a server-side chase (UPs flowing → UseTime never ran → legs stayed Ready while the body glided = the #170 slide; live funnel 16 arms → 1 run install). **R5-V3 (#171) narrowed it again: a STUCK entity (`PositionManager.GetStickyObjectId() != 0`) also takes the MOVETO leg** — after the sticky arrival the moveto is cleaned (Invalid) but `StickyManager::adjust_offset` owns the between-snap translation; SERVERVEL would glide the body against the sticky steer (same starvation class) | `src/AcDream.App/Rendering/GameWindow.cs` (`TickAnimations` grounded NPC branch, `moveToArmed`+`stickyArmed` gate) | ACE moves some entities by position updates alone (scripted paths, missiles) with no UM/moveto stream — without a velocity fallback they freeze between UPs; entities WITH a moveto now get the retail drive | An entity class that carries BOTH wire velocity and an armed moveto with conflicting truths follows the moveto; UP hard-snaps bound the drift. Non-moveto entities keep the non-retail stale-stop heuristics (AP-80 thresholds) | `CPhysicsObj::UpdateObjectInternal` 0x005156b0 (`MovementManager::UseTime` call @0x00515998, unconditional); retire in R6 (full per-tick order) |
| TS-42 | Per-tick DRAIN ORDER inverted vs retail: acdream's `TickAnimations` runs `HandleTargetting``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-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 hard-snaps (position @`OnLivePositionUpdated` + orientation + velocity/cycle adoption) are SUPPRESSED while the entity is stuck (`PositionManager.GetStickyObjectId() != 0`) — an adaptation of retail's chain semantics to the legacy snap path: retail routes UP corrections through the InterpolationManager into the SAME per-tick `PositionManager::adjust_offset` chain where `StickyManager::adjust_offset` OVERWRITES them while armed (0x00555190 order, 0x00555430 assigns m_fOrigin), so a server correction can never fight an armed stick; the legacy NPC path snaps OUTSIDE the chain, producing snap-out/steer-back position flapping + stale-facing stomps (the 2026-07-04 #171 gate residuals). Bookkeeping (`LastServerPos/Time`, cell) still records; server truth reasserts on the first UP after unstick, bounded by the 1 s sticky lease | `src/AcDream.App/Rendering/GameWindow.cs` (`OnLivePositionUpdated` NPC section, `snapSuppressedByStick` gate) | Retail's mechanism (sticky-overwrites-interp) is unreachable until the NPC path gets the interp-queue architecture (the player-remote branch already has it — the R5-V3 combiner→sticky chain); the gate reproduces the retail-observable behavior on the snap architecture | A stick that stays armed while ACE moves the monster far (shouldn't happen — sticks follow the target by construction) would drift until unstick+next UP; worst case bounded by the 1 s lease + the next UM re-arm | `PositionManager::adjust_offset` 0x00555190; `InterpolationManager` UP routing (`CPhysicsObj::MoveOrTeleport`); retire when the NPC path unifies onto the interp queue (S6/R6) | | TS-44 | NPC UpdatePosition hard-snaps (position @`OnLivePositionUpdated` + orientation + velocity/cycle adoption) are SUPPRESSED while the entity is stuck (`PositionManager.GetStickyObjectId() != 0`) — an adaptation of retail's chain semantics to the legacy snap path: retail routes UP corrections through the InterpolationManager into the SAME per-tick `PositionManager::adjust_offset` chain where `StickyManager::adjust_offset` OVERWRITES them while armed (0x00555190 order, 0x00555430 assigns m_fOrigin), so a server correction can never fight an armed stick; the legacy NPC path snaps OUTSIDE the chain, producing snap-out/steer-back position flapping + stale-facing stomps (the 2026-07-04 #171 gate residuals). Bookkeeping (`LastServerPos/Time`, cell) still records; server truth reasserts on the first UP after unstick, bounded by the 1 s sticky lease | `src/AcDream.App/Rendering/GameWindow.cs` (`OnLivePositionUpdated` NPC section, `snapSuppressedByStick` gate) | Retail's mechanism (sticky-overwrites-interp) is unreachable until the NPC path gets the interp-queue architecture (the player-remote branch already has it — the R5-V3 combiner→sticky chain); the gate reproduces the retail-observable behavior on the snap architecture | A stick that stays armed while ACE moves the monster far (shouldn't happen — sticks follow the target by construction) would drift until unstick+next UP; worst case bounded by the 1 s lease + the next UM re-arm | `PositionManager::adjust_offset` 0x00555190; `InterpolationManager` UP routing (`CPhysicsObj::MoveOrTeleport`); retire when the NPC path unifies onto the interp queue (S6/R6) |
| TS-43 | Remote teleport has no `teleport_hook` equivalent: retail tears down the position managers on every teleport (`CPhysicsObj::teleport_hook` 0x00514ed0 — `CancelMoveTo(0x3c)` @0x00514edf, `PositionManager::UnStick` @0x00514eee, `StopInterpolating`/`UnConstrain`); acdream's remote teleport is a bare UP hard-snap, so a stuck/chasing remote that the server teleports keeps its stick/moveto for up to the 1 s sticky lease / next UM. The LOCAL player side IS wired (R5-V3: `PlayerMovementController.SetPosition``PositionManager.UnStick`; the moveto cancel was already there via `StopCompletely`; the teleport-arrival site also fires the hook's tail — `EntityPhysicsHost.NotifyTeleported` = `TargetManager::ClearTarget` + `NotifyVoyeurOfEvent(Teleported)` @0x00514f1b-0x00514f28, which is what makes mobs stuck to the player drop their sticks on a recall) | `src/AcDream.App/Rendering/GameWindow.cs` (`OnLivePositionUpdated` — no teleport-flag manager teardown for remotes) | Remote teleports are rare (recalls/summons); the sticky 1 s lease + UP hard-snaps self-correct within a second; wiring it properly wants the UP teleport-stamp plumbing (TS-26's stamp work) | A teleported-away attacker briefly steers toward its pre-teleport target from the new location (≤1 s) before the lease/next-UM corrects it | `CPhysicsObj::teleport_hook` 0x00514ed0; retire with the TS-26 UP-stamp port | | TS-43 | Remote teleport has no `teleport_hook` equivalent: retail tears down the position managers on every teleport (`CPhysicsObj::teleport_hook` 0x00514ed0 — `CancelMoveTo(0x3c)` @0x00514edf, `PositionManager::UnStick` @0x00514eee, `StopInterpolating`/`UnConstrain`); acdream's remote teleport is a bare UP hard-snap, so a stuck/chasing remote that the server teleports keeps its stick/moveto for up to the 1 s sticky lease / next UM. The LOCAL player side IS wired (R5-V3: `PlayerMovementController.SetPosition``PositionManager.UnStick`; the moveto cancel was already there via `StopCompletely`; the teleport-arrival site also fires the hook's tail — `EntityPhysicsHost.NotifyTeleported` = `TargetManager::ClearTarget` + `NotifyVoyeurOfEvent(Teleported)` @0x00514f1b-0x00514f28, which is what makes mobs stuck to the player drop their sticks on a recall) | `src/AcDream.App/Rendering/GameWindow.cs` (`OnLivePositionUpdated` — no teleport-flag manager teardown for remotes) | Remote teleports are rare (recalls/summons); the sticky 1 s lease + UP hard-snaps self-correct within a second; wiring it properly wants the UP teleport-stamp plumbing (TS-26's stamp work) | A teleported-away attacker briefly steers toward its pre-teleport target from the new location (≤1 s) before the lease/next-UM corrects it | `CPhysicsObj::teleport_hook` 0x00514ed0; retire with the TS-26 UP-stamp port |

View file

@ -268,23 +268,46 @@ public sealed class PlayerMovementController
// MoveToManager remotes use (R4-V4), bound below by GameWindow's // MoveToManager remotes use (R4-V4), bound below by GameWindow's
// EnterPlayerModeNow beside the R3-W6 DefaultSink bind. // EnterPlayerModeNow beside the R3-W6 DefaultSink bind.
/// <summary>
/// R5-V5: retail <c>CPhysicsObj::movement_manager</c> (acclient.h
/// <c>/* 3463 */</c>) — the ONE owner of this controller's
/// <see cref="Motion"/> + <see cref="MoveTo"/> pair. Constructed in the
/// ctor around the interp; the MoveToManager side arrives via
/// <c>MoveToFactory</c> + <c>MakeMoveToManager()</c> in
/// <c>GameWindow.EnterPlayerModeNow</c> (the same facade shape
/// <c>EnsureRemoteMotionBindings</c> gives remotes). <see cref="Update"/>
/// ticks <c>UseTime()</c> (0x005242f0) at the slot the deleted
/// <c>DriveServerAutoWalk</c> occupied and relays <c>HitGround()</c>
/// (0x00524300, minterp first then moveto).
/// </summary>
public AcDream.Core.Physics.Motion.MovementManager Movement { get; }
/// <summary> /// <summary>
/// R4-V5: the local player's verbatim retail <c>MoveToManager</c> /// R4-V5: the local player's verbatim retail <c>MoveToManager</c>
/// (decomp 0x00529010-0x0052a987), constructed + seam-bound by /// (decomp 0x00529010-0x0052a987), constructed + seam-bound by
/// <c>GameWindow.EnterPlayerModeNow</c> against this controller's /// <c>GameWindow.EnterPlayerModeNow</c> against this controller's
/// <see cref="Motion"/>/body/Yaw (the same wiring shape /// <see cref="Motion"/>/body/Yaw (the same wiring shape
/// <c>EnsureRemoteMotionBindings</c> uses for remotes). GameWindow /// <c>EnsureRemoteMotionBindings</c> uses for remotes). GameWindow
/// routes inbound mt 6-9 movement events to /// routes inbound mt 6-9 movement events through
/// <see cref="AcDream.Core.Physics.Motion.MoveToManager.PerformMovement"/>; /// <see cref="AcDream.Core.Physics.Motion.MovementManager.PerformMovement"/>.
/// <see cref="Update"/> ticks <c>UseTime()</c> at the slot the deleted /// User input cancels a moveto through the retail chain: key edge →
/// <c>DriveServerAutoWalk</c> occupied and relays <c>HitGround()</c> /// DoMotion (ctor-default params, CancelMoveTo bit set) →
/// (retail order: minterp first, then moveto — MovementManager::HitGround
/// 0x00524300). User input cancels a moveto through the retail chain:
/// key edge → DoMotion (ctor-default params, CancelMoveTo bit set) →
/// <see cref="MotionInterpreter.InterruptCurrentMovement"/> → /// <see cref="MotionInterpreter.InterruptCurrentMovement"/> →
/// <c>CancelMoveTo(ActionCancelled)</c> (register TS-36 retired). /// <c>CancelMoveTo(ActionCancelled)</c> (register TS-36 retired).
/// R5-V5: a view of <see cref="Movement"/>'s moveto child; the setter is
/// sugar over the facade's factory path (kept for the
/// PlayerMoveToCutoverTests rig and any pre-facade bind shape).
/// </summary> /// </summary>
public AcDream.Core.Physics.Motion.MoveToManager? MoveTo { get; set; } public AcDream.Core.Physics.Motion.MoveToManager? MoveTo
{
get => Movement.MoveTo;
set
{
var mtm = value ?? throw new ArgumentNullException(nameof(value));
Movement.MoveToFactory = () => mtm;
Movement.MakeMoveToManager();
}
}
/// <summary> /// <summary>
/// R5-V3 (#171): the player's <c>PositionManager</c> facade (retail /// R5-V3 (#171): the player's <c>PositionManager</c> facade (retail
@ -327,6 +350,10 @@ public sealed class PlayerMovementController
int jumpSkill = int.TryParse(Environment.GetEnvironmentVariable("ACDREAM_JUMP_SKILL"), out var jsv) ? jsv : 300; int jumpSkill = int.TryParse(Environment.GetEnvironmentVariable("ACDREAM_JUMP_SKILL"), out var jsv) ? jsv : 300;
_weenie = new PlayerWeenie(runSkill: runSkill, jumpSkill: jumpSkill); _weenie = new PlayerWeenie(runSkill: runSkill, jumpSkill: jumpSkill);
_motion = new MotionInterpreter(_body, _weenie); _motion = new MotionInterpreter(_body, _weenie);
// R5-V5: the MovementManager facade owns the interp from birth
// (retail CPhysicsObj::movement_manager); the moveto child binds
// later via MoveToFactory (EnterPlayerModeNow / the test rigs).
Movement = new AcDream.Core.Physics.Motion.MovementManager(_motion);
// R3-W4 (A3): the local player's movement is input-driven — // R3-W4 (A3): the local player's movement is input-driven —
// movement_is_autonomous true so apply_current_movement's dual // movement_is_autonomous true so apply_current_movement's dual
// dispatch routes apply_raw_movement (IsThePlayer && autonomous). // dispatch routes apply_raw_movement (IsThePlayer && autonomous).
@ -528,8 +555,9 @@ public sealed class PlayerMovementController
// user motion and never builds a MoveToState mid-moveto (the #75 // user motion and never builds a MoveToState mid-moveto (the #75
// invariant, now by construction). Provisional tick placement until // invariant, now by construction). Provisional tick placement until
// R6 ports retail's UpdateObjectInternal ordering (r4-port-plan.md // R6 ports retail's UpdateObjectInternal ordering (r4-port-plan.md
// §3 placement decision). // §3 placement decision). R5-V5: the facade relay
MoveTo?.UseTime(); // (MovementManager::UseTime 0x005242f0 — moveto side only).
Movement.UseTime();
// R4-V5 wedge fix (2026-07-03 live door bug), retail's per-tick // R4-V5 wedge fix (2026-07-03 live door bug), retail's per-tick
// completion slot: CPartArray::HandleMovement — a tailcall chain to // completion slot: CPartArray::HandleMovement — a tailcall chain to
@ -1033,13 +1061,13 @@ public sealed class PlayerMovementController
if (wasAirborne) if (wasAirborne)
{ {
_motion.HitGround(); // R4-V5 → R5-V5: retail order — minterp first, then
// R4-V5: retail order — minterp first, then moveto // moveto (MovementManager::HitGround 0x00524300, decomp
// (MovementManager::HitGround 0x00524300, decomp §2d); // §2d — now the literal facade relay); re-arms a moveto
// re-arms a moveto suspended by the airborne UseTime // suspended by the airborne UseTime contact gate.
// contact gate. LeaveGround has NO moveto side (COMDAT // LeaveGround has NO moveto side (COMDAT no-op, §2e) —
// no-op, §2e) — do not add one. // do not add one.
MoveTo?.HitGround(); Movement.HitGround();
justLanded = true; justLanded = true;
} }
} }

View file

@ -411,7 +411,18 @@ public sealed class GameWindow : IDisposable
internal sealed class RemoteMotion // internal: the R2-Q5 sink callbacks (ObservedOmega seam) capture it internal sealed class RemoteMotion // internal: the R2-Q5 sink callbacks (ObservedOmega seam) capture it
{ {
public AcDream.Core.Physics.PhysicsBody Body; public AcDream.Core.Physics.PhysicsBody Body;
public AcDream.Core.Physics.MotionInterpreter Motion;
/// <summary>R5-V5: retail <c>CPhysicsObj::movement_manager</c> — the
/// ONE per-entity owner of the interp + moveto pair (acclient.h
/// <c>/* 3463 */</c>). Constructed here with the interp; the
/// MoveToManager side arrives via <c>MoveToFactory</c> +
/// <c>MakeMoveToManager()</c> in EnsureRemoteMotionBindings.
/// <see cref="Motion"/>/<see cref="MoveTo"/> below are views of its
/// children (retail <c>get_minterp</c>-style access), kept so the
/// dozens of existing call sites read unchanged.</summary>
public AcDream.Core.Physics.Motion.MovementManager Movement;
public AcDream.Core.Physics.MotionInterpreter Motion => Movement.Minterp;
/// <summary>R3-W4: the persistent per-remote dispatch sink (created /// <summary>R3-W4: the persistent per-remote dispatch sink (created
/// once by EnsureRemoteMotionBindings; also bound as /// once by EnsureRemoteMotionBindings; also bound as
/// Motion.DefaultSink so LeaveGround/HitGround re-applies dispatch /// Motion.DefaultSink so LeaveGround/HitGround re-applies dispatch
@ -432,8 +443,9 @@ public sealed class GameWindow : IDisposable
/// <summary>R4-V4: the entity's verbatim retail MoveToManager /// <summary>R4-V4: the entity's verbatim retail MoveToManager
/// (server-directed movement), constructed + seam-bound by /// (server-directed movement), constructed + seam-bound by
/// EnsureRemoteMotionBindings beside the R2-Q5/R3 binds.</summary> /// EnsureRemoteMotionBindings beside the R2-Q5/R3 binds. R5-V5:
public AcDream.Core.Physics.Motion.MoveToManager? MoveTo; /// owned by <see cref="Movement"/>; this is the child view.</summary>
public AcDream.Core.Physics.Motion.MoveToManager? MoveTo => Movement.MoveTo;
// R5-V2: the entity's CPhysicsObj stand-in — owns the TargetManager // R5-V2: the entity's CPhysicsObj stand-in — owns the TargetManager
// voyeur system (retail CPhysicsObj::target_manager). Replaces the // voyeur system (retail CPhysicsObj::target_manager). Replaces the
@ -607,15 +619,20 @@ public sealed class GameWindow : IDisposable
// transition link (see PhysicsBody.InWorld). // transition link (see PhysicsBody.InWorld).
InWorld = true, InWorld = true,
}; };
Motion = new AcDream.Core.Physics.MotionInterpreter(Body) // R5-V5: the interp is owned by the MovementManager facade
{ // (retail CPhysicsObj::movement_manager -> motion_interpreter).
// R4-V5 #160 fix: retail remotes carry a weenie whose // acdream constructs it eagerly here — retail's lazy-create
// InqRunRate FAILS, landing apply_run_to_command on // window is never observable (see MovementManager's class doc).
// my_run_rate (the M13 wire feed). A null weenie took the Movement = new AcDream.Core.Physics.Motion.MovementManager(
// degenerate 1.0 branch — observer-side run movetos played new AcDream.Core.Physics.MotionInterpreter(Body)
// and moved in slow motion. See RemoteWeenie. {
WeenieObj = new AcDream.Core.Physics.RemoteWeenie(), // R4-V5 #160 fix: retail remotes carry a weenie whose
}; // InqRunRate FAILS, landing apply_run_to_command on
// my_run_rate (the M13 wire feed). A null weenie took the
// degenerate 1.0 branch — observer-side run movetos played
// and moved in slow motion. See RemoteWeenie.
WeenieObj = new AcDream.Core.Physics.RemoteWeenie(),
});
} }
} }
@ -4282,6 +4299,11 @@ public sealed class GameWindow : IDisposable
// space on both sides (getPosition + the MovementStruct positions // space on both sides (getPosition + the MovementStruct positions
// the UM router builds) — one consistent space, so the manager's // the UM router builds) — one consistent space, so the manager's
// distance/heading math matches the harness exactly. // distance/heading math matches the harness exactly.
// R5-V5: the construction is now the MovementManager facade's
// MoveToFactory (the acdream stand-in for the physics_obj/weenie_obj
// backpointers retail's MakeMoveToManager 0x00524000 constructs
// from); MakeMoveToManager() below invokes it once, preserving the
// pre-facade eager timing.
var rmT = rm; var rmT = rm;
var mtBody = rm.Body; var mtBody = rm.Body;
// R5-V3 (#171): real setup cylsphere radii — retail CPartArray:: // R5-V3 (#171): real setup cylsphere radii — retail CPartArray::
@ -4297,35 +4319,50 @@ public sealed class GameWindow : IDisposable
// TargetManager::SetTarget). Assigned right after the manager is built. // TargetManager::SetTarget). Assigned right after the manager is built.
EntityPhysicsHost host = null!; EntityPhysicsHost host = null!;
double NowSeconds() => (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds; double NowSeconds() => (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds;
rm.MoveTo = new AcDream.Core.Physics.Motion.MoveToManager( rm.Movement.MoveToFactory = () =>
rm.Motion, {
stopCompletely: () => rmT.Motion.StopCompletely(), var mtm = new AcDream.Core.Physics.Motion.MoveToManager(
getPosition: () => new AcDream.Core.Physics.Position( rm.Motion,
rmT.CellId, mtBody.Position, mtBody.Orientation), stopCompletely: () => rmT.Motion.StopCompletely(),
getHeading: () => AcDream.Core.Physics.Motion.MoveToMath.GetHeading( getPosition: () => new AcDream.Core.Physics.Position(
mtBody.Orientation), rmT.CellId, mtBody.Position, mtBody.Orientation),
setHeading: (h, _) => mtBody.Orientation = getHeading: () => AcDream.Core.Physics.Motion.MoveToMath.GetHeading(
AcDream.Core.Physics.Motion.MoveToMath.SetHeading(mtBody.Orientation, h), mtBody.Orientation),
getOwnRadius: () => GetSetupCylinder(serverGuid, selfEntity).Radius, setHeading: (h, _) => mtBody.Orientation =
getOwnHeight: () => GetSetupCylinder(serverGuid, selfEntity).Height, AcDream.Core.Physics.Motion.MoveToMath.SetHeading(mtBody.Orientation, h),
contact: () => mtBody.OnWalkable, getOwnRadius: () => GetSetupCylinder(serverGuid, selfEntity).Radius,
isInterpolating: () => rmT.Interp.IsActive, getOwnHeight: () => GetSetupCylinder(serverGuid, selfEntity).Height,
getVelocity: () => mtBody.Velocity, contact: () => mtBody.OnWalkable,
getSelfId: () => serverGuid, isInterpolating: () => rmT.Interp.IsActive,
// R5-V2: retail CPhysicsObj::set_target/clear_target/target_quantum getVelocity: () => mtBody.Velocity,
// → the entity's TargetManager (replaces the AP-79 TrackedTarget* getSelfId: () => serverGuid,
// poll). The manager passes (0, top_level_id, 0.5, 0.0). // R5-V2: retail CPhysicsObj::set_target/clear_target/target_quantum
setTarget: (ctx, tlid, radius, q) => host.SetTarget(ctx, tlid, radius, q), // → the entity's TargetManager (replaces the AP-79 TrackedTarget*
clearTarget: () => host.ClearTarget(), // poll). The manager passes (0, top_level_id, 0.5, 0.0).
getTargetQuantum: () => host.TargetManager.GetTargetQuantum(), setTarget: (ctx, tlid, radius, q) => host.SetTarget(ctx, tlid, radius, q),
setTargetQuantum: q => host.TargetManager.SetTargetQuantum(q), clearTarget: () => host.ClearTarget(),
// R4-V5: real clock (same epoch-seconds base the per-remote tick uses). getTargetQuantum: () => host.TargetManager.GetTargetQuantum(),
curTime: NowSeconds); setTargetQuantum: q => host.TargetManager.SetTargetQuantum(q),
// R4-V5: real clock (same epoch-seconds base the per-remote tick uses).
curTime: NowSeconds);
// R5-V3 (#171, retires TS-39): bind the sticky seams to the host's
// PositionManager (host is constructed before MakeMoveToManager
// invokes this factory) —
// * BeginNextNode's sticky-arrival handoff: PositionManager::StickTo
// (retail MoveToManager::BeginNextNode @0x00529d3a);
// * PerformMovement's head unstick: unstick_from_object →
// PositionManager::UnStick (MoveToManager.PerformMovement:414).
mtm.StickTo = (tlid, radius, height) =>
host.PositionManager.StickTo(tlid, radius, height);
mtm.Unstick = host.PositionManager.UnStick;
return mtm;
};
// TS-36 (remote side): the interp's interrupt seam is retail's // TS-36 (remote side): the interp's interrupt seam is retail's
// interrupt_current_movement → MovementManager::CancelMoveTo(0x36) // interrupt_current_movement → MovementManager::CancelMoveTo(0x36)
// chain (V2's reentrancy tests prove the wiring is inert-safe). // chain (V2's reentrancy tests prove the wiring is inert-safe;
// R5-V5: the chain now lands on the literal facade relay 0x005241b0).
rm.Motion.InterruptCurrentMovement = rm.Motion.InterruptCurrentMovement =
() => rmT.MoveTo?.CancelMoveTo( () => rmT.Movement.CancelMoveTo(
AcDream.Core.Physics.WeenieError.ActionCancelled); AcDream.Core.Physics.WeenieError.ActionCancelled);
// R5-V2: the entity's CPhysicsObj stand-in + its TargetManager. Position // R5-V2: the entity's CPhysicsObj stand-in + its TargetManager. Position
@ -4350,24 +4387,22 @@ public sealed class GameWindow : IDisposable
curTime: NowSeconds, curTime: NowSeconds,
physicsTimerTime: NowSeconds, physicsTimerTime: NowSeconds,
getObjectA: ResolvePhysicsHost, getObjectA: ResolvePhysicsHost,
handleUpdateTarget: info => rmT.MoveTo?.HandleUpdateTarget(info), // Retail CPhysicsObj::HandleUpdateTarget (0x00512bc0) fan head:
interruptCurrentMovement: () => rmT.MoveTo?.CancelMoveTo( // MovementManager::HandleUpdateTarget (@0x00512bf0 — the facade
// relay to the moveto side); the host chains the PositionManager
// leg after it.
handleUpdateTarget: info => rmT.Movement.HandleUpdateTarget(info),
interruptCurrentMovement: () => rmT.Movement.CancelMoveTo(
AcDream.Core.Physics.WeenieError.ActionCancelled)); AcDream.Core.Physics.WeenieError.ActionCancelled));
rm.Host = host; rm.Host = host;
_physicsHosts[serverGuid] = host; _physicsHosts[serverGuid] = host;
// R5-V3 (#171, retires TS-39): bind the sticky seams to the host's // R5-V5: retail MakeMoveToManager (0x00524000) — constructs the
// PositionManager — // MoveToManager via the factory above (host now exists for the
// * BeginNextNode's sticky-arrival handoff: PositionManager::StickTo // sticky binds inside it). The UM funnel head's unstick
// (retail MoveToManager::BeginNextNode @0x00529d3a); // (CPhysicsObj::unstick_from_object 0x0050eaea, invoked at the mt-0
// * PerformMovement's head unstick: unstick_from_object → // routing sites) binds on the interp beside it.
// PositionManager::UnStick (MoveToManager.PerformMovement:414); rm.Movement.MakeMoveToManager();
// * the UM funnel head's unstick: CPhysicsObj::unstick_from_object
// (0x0050eaea) — invoked at the mt-0 routing sites (~4894) but
// unbound until now.
rm.MoveTo.StickTo = (tlid, radius, height) =>
host.PositionManager.StickTo(tlid, radius, height);
rm.MoveTo.Unstick = host.PositionManager.UnStick;
rm.Motion.UnstickFromObject = host.PositionManager.UnStick; rm.Motion.UnstickFromObject = host.PositionManager.UnStick;
return rm.Sink; return rm.Sink;
} }
@ -4506,11 +4541,12 @@ public sealed class GameWindow : IDisposable
// independently (r3-port-plan §4): the manager's (each pending // independently (r3-port-plan §4): the manager's (each pending
// animation fires MotionDone(success:false) → the bound interp pops // animation fires MotionDone(success:false) → the bound interp pops
// in step) THEN the interp's own (flushes any remainder — // in step) THEN the interp's own (flushes any remainder —
// CMotionInterp::HandleExitWorld 0x00527f30). // MovementManager::HandleExitWorld 0x00524350, the R5-V5 facade
// relay: interp ONLY → CMotionInterp::HandleExitWorld 0x00527f30).
if (_animatedEntities.TryGetValue(existingEntity.Id, out var aeGone)) if (_animatedEntities.TryGetValue(existingEntity.Id, out var aeGone))
aeGone.Sequencer?.Manager.HandleExitWorld(); aeGone.Sequencer?.Manager.HandleExitWorld();
if (_remoteDeadReckon.TryGetValue(serverGuid, out var rmGone)) if (_remoteDeadReckon.TryGetValue(serverGuid, out var rmGone))
rmGone.Motion.HandleExitWorld(); rmGone.Movement.HandleExitWorld();
// R5-V2: retail CPhysicsObj::exit_world tells every voyeur of this // R5-V2: retail CPhysicsObj::exit_world tells every voyeur of this
// entity that it left the world (TargetManager::NotifyVoyeurOfEvent // entity that it left the world (TargetManager::NotifyVoyeurOfEvent
// ExitWorld) — a watcher moving-to/stuck-to this entity drops the // ExitWorld) — a watcher moving-to/stuck-to this entity drops the
@ -4565,13 +4601,16 @@ public sealed class GameWindow : IDisposable
/// builds the <c>MovementStruct</c> (mt 6/8 resolve the target guid /// builds the <c>MovementStruct</c> (mt 6/8 resolve the target guid
/// against the entity table; unresolvable degrades to /// against the entity table; unresolvable degrades to
/// MoveToPosition(wire origin) / TurnToHeading(wire heading) per §2f — /// MoveToPosition(wire origin) / TurnToHeading(wire heading) per §2f —
/// NOT an error), and calls <c>PerformMovement</c>. Returns true when /// NOT an error), and calls the R5-V5 facade's <c>PerformMovement</c>
/// the event was a type-6..9 moveto (consumed); false for every other /// (<c>MovementManager::PerformMovement</c> 0x005240d0 — types 6-9
/// movement type (caller falls through to its funnel / skip posture). /// MakeMoveToManager + delegate; retail's cases call MakeMoveToManager
/// at each head, a no-op here since the bind sites create eagerly).
/// Returns true when the event was a type-6..9 moveto (consumed); false
/// for every other movement type (caller falls through to its funnel /
/// skip posture).
/// </summary> /// </summary>
private bool RouteServerMoveTo( private bool RouteServerMoveTo(
AcDream.Core.Physics.Motion.MoveToManager mgr, AcDream.Core.Physics.Motion.MovementManager movement,
AcDream.Core.Physics.MotionInterpreter interp,
uint cellId, uint cellId,
AcDream.Core.Net.WorldSession.EntityMotionUpdate update) AcDream.Core.Net.WorldSession.EntityMotionUpdate update)
{ {
@ -4580,7 +4619,7 @@ public sealed class GameWindow : IDisposable
{ {
// my_run_rate write (unpack_movement @300603). // my_run_rate write (unpack_movement @300603).
if (update.MotionState.MoveToRunRate is { } mtRunRate) if (update.MotionState.MoveToRunRate is { } mtRunRate)
interp.MyRunRate = mtRunRate; movement.Minterp.MyRunRate = mtRunRate;
var destWorld = AcDream.Core.Physics.Motion.MoveToMath var destWorld = AcDream.Core.Physics.Motion.MoveToMath
.OriginToWorld( .OriginToWorld(
@ -4629,7 +4668,7 @@ public sealed class GameWindow : IDisposable
cellId, destWorld, cellId, destWorld,
System.Numerics.Quaternion.Identity); System.Numerics.Quaternion.Identity);
} }
mgr.PerformMovement(ms); movement.PerformMovement(ms);
return true; return true;
} }
@ -4669,7 +4708,7 @@ public sealed class GameWindow : IDisposable
mp.DesiredHeading = wireHeading; mp.DesiredHeading = wireHeading;
} }
} }
mgr.PerformMovement(ms); movement.PerformMovement(ms);
return true; return true;
} }
@ -4677,8 +4716,9 @@ public sealed class GameWindow : IDisposable
} }
/// <summary> /// <summary>
/// R4-V5 / R5-V2: the per-tick <see cref="AcDream.Core.Physics.Motion.MoveToManager"/> /// R4-V5 / R5-V2: the per-tick <see cref="AcDream.Core.Physics.Motion.MovementManager"/>
/// drive (<c>UseTime()</c> — steering, arrival, fail-distance). The P4 /// drive (retail <c>MovementManager::UseTime</c> 0x005242f0 — the moveto
/// side's steering, arrival, fail-distance; R5-V5 facade relay). The P4
/// TargetTracker POLL is gone (R5-V2): target-position delivery now flows /// TargetTracker POLL is gone (R5-V2): target-position delivery now flows
/// through the <see cref="AcDream.Core.Physics.Motion.TargetManager"/> /// through the <see cref="AcDream.Core.Physics.Motion.TargetManager"/>
/// voyeur system — the target's own <c>HandleTargetting</c> pushes updates /// voyeur system — the target's own <c>HandleTargetting</c> pushes updates
@ -4690,7 +4730,7 @@ public sealed class GameWindow : IDisposable
/// </summary> /// </summary>
private void TickRemoteMoveTo(RemoteMotion rm) private void TickRemoteMoveTo(RemoteMotion rm)
{ {
rm.MoveTo?.UseTime(); rm.Movement.UseTime();
} }
/// <summary> /// <summary>
@ -4975,14 +5015,14 @@ public sealed class GameWindow : IDisposable
_playerController.Motion.DoMotion(wireStylePlayer, _playerController.Motion.DoMotion(wireStylePlayer,
new AcDream.Core.Physics.Motion.MovementParameters()); new AcDream.Core.Physics.Motion.MovementParameters());
if (_playerController.MoveTo is { } playerMoveTo if (_playerController.MoveTo is not null
&& RouteServerMoveTo(playerMoveTo, _playerController.Motion, && RouteServerMoveTo(_playerController.Movement,
_playerController.CellId, update)) _playerController.CellId, update))
{ {
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled) if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
{ {
Console.WriteLine(System.FormattableString.Invariant( Console.WriteLine(System.FormattableString.Invariant(
$"[autowalk-begin] mt=0x{update.MotionState.MovementType:X2} movingTo={playerMoveTo.IsMovingTo()} type={playerMoveTo.MovementTypeState}")); $"[autowalk-begin] mt=0x{update.MotionState.MovementType:X2} movingTo={_playerController.Movement.IsMovingTo()} type={_playerController.MoveTo.MovementTypeState}"));
} }
return; return;
} }
@ -5067,8 +5107,10 @@ public sealed class GameWindow : IDisposable
// R4-V5: the type-6..9 routing body is shared with the // R4-V5: the type-6..9 routing body is shared with the
// local player (RouteServerMoveTo) — behavior identical // local player (RouteServerMoveTo) — behavior identical
// to the R4-V4 inline blocks it was extracted from. // to the R4-V4 inline blocks it was extracted from.
if (remoteMot.MoveTo is { } moveMgr // (MoveTo null = EnsureRemoteMotionBindings early-outed
&& RouteServerMoveTo(moveMgr, remoteMot.Motion, // on a sequencer-less entity — same skip as pre-V5.)
if (remoteMot.MoveTo is not null
&& RouteServerMoveTo(remoteMot.Movement,
remoteMot.CellId, update)) remoteMot.CellId, update))
{ {
return; return;
@ -5657,21 +5699,21 @@ public sealed class GameWindow : IDisposable
rmState.Body.Position = worldPos; rmState.Body.Position = worldPos;
// #161: retail landing = MovementManager::HitGround // #161: retail landing = MovementManager::HitGround
// (minterp → moveto, 0x00524300) with the Gravity state // (minterp → moveto, 0x00524300 — the R5-V5 facade
// bit STILL SET (CMotionInterp::HitGround gates on // relay) with the Gravity state bit STILL SET
// state&0x400). The re-apply dispatches the PRESERVED // (CMotionInterp::HitGround gates on state&0x400). The
// pre-fall forward command → landing link → cycle. This // re-apply dispatches the PRESERVED pre-fall forward
// replaces the forced SetCycle, which read the // command → landing link → cycle. This replaces the
// then-clobbered ForwardCommand (Falling) and re-set the // forced SetCycle, which read the then-clobbered
// pose it meant to clear. See the twin block in // ForwardCommand (Falling) and re-set the pose it meant
// TickAnimations (VU.land). // to clear. See the twin block in TickAnimations
// (VU.land).
if (_animatedEntities.TryGetValue(entity.Id, out var aeForLand) if (_animatedEntities.TryGetValue(entity.Id, out var aeForLand)
&& aeForLand.Sequencer is not null) && aeForLand.Sequencer is not null)
{ {
EnsureRemoteMotionBindings(rmState, aeForLand, update.Guid); EnsureRemoteMotionBindings(rmState, aeForLand, update.Guid);
} }
rmState.Motion.HitGround(); rmState.Movement.HitGround();
rmState.MoveTo?.HitGround();
// DR bookkeeping only (partner of the jump-start // DR bookkeeping only (partner of the jump-start
// `State |= Gravity`). // `State |= Gravity`).
rmState.Body.State &= ~AcDream.Core.Physics.PhysicsStateFlags.Gravity; rmState.Body.State &= ~AcDream.Core.Physics.PhysicsStateFlags.Gravity;
@ -10449,15 +10491,15 @@ public sealed class GameWindow : IDisposable
// then-clobbered InterpretedState.ForwardCommand // then-clobbered InterpretedState.ForwardCommand
// — 0x40000015 — and re-set the very Falling // — 0x40000015 — and re-set the very Falling
// cycle it meant to clear.) // cycle it meant to clear.)
rm.Motion.HitGround();
// R4-V5 (closes the V4 wiring-contract gap the // R4-V5 (closes the V4 wiring-contract gap the
// adversarial review caught): retail order — // adversarial review caught): retail order —
// minterp first, then moveto (MovementManager:: // minterp first, then moveto (MovementManager::
// HitGround 0x00524300, §2d). Re-arms a moveto // HitGround 0x00524300, §2d — the R5-V5 facade
// suspended by the airborne UseTime contact gate; // relay). Re-arms a moveto suspended by the
// without it a chasing NPC that lands stalls // airborne UseTime contact gate; without it a
// until ACE's ~1 Hz re-emit. // chasing NPC that lands stalls until ACE's
rm.MoveTo?.HitGround(); // ~1 Hz re-emit.
rm.Movement.HitGround();
// DR bookkeeping only (partner of the jump-start // DR bookkeeping only (partner of the jump-start
// `State |= Gravity`): stops the per-tick gravity // `State |= Gravity`): stops the per-tick gravity
// integration for the grounded body. // integration for the grounded body.
@ -12750,7 +12792,7 @@ public sealed class GameWindow : IDisposable
private void InstallSpeculativeTurnToTarget(uint targetGuid) private void InstallSpeculativeTurnToTarget(uint targetGuid)
{ {
if (_playerController?.MoveTo is not { } playerMoveTo) return; if (_playerController is not { } pc || pc.MoveTo is null) return;
if (!_entitiesByServerGuid.TryGetValue(targetGuid, out var entity)) if (!_entitiesByServerGuid.TryGetValue(targetGuid, out var entity))
return; return;
@ -12829,7 +12871,10 @@ public sealed class GameWindow : IDisposable
// set it autonomous) and clobbers this moveto's dispatched motions // set it autonomous) and clobbers this moveto's dispatched motions
// with the idle raw state. // with the idle raw state.
_playerController.SetLastMoveWasAutonomous(false); _playerController.SetLastMoveWasAutonomous(false);
playerMoveTo.PerformMovement(ms); // R5-V5: through the facade (MovementManager::PerformMovement
// 0x005240d0) — retail's client-initiated use flow reaches the same
// manager the wire path uses.
pc.Movement.PerformMovement(ms);
} }
private uint? SelectClosestCombatTarget(bool showToast) private uint? SelectClosestCombatTarget(bool showToast)
@ -13399,27 +13444,57 @@ public sealed class GameWindow : IDisposable
// R5-V2: forward-declared so the player MoveToManager's target seams // R5-V2: forward-declared so the player MoveToManager's target seams
// route into the player's TargetManager (retail CPhysicsObj::set_target). // route into the player's TargetManager (retail CPhysicsObj::set_target).
EntityPhysicsHost playerHost = null!; EntityPhysicsHost playerHost = null!;
var playerMoveTo = new AcDream.Core.Physics.Motion.MoveToManager( // R5-V5: the construction is the player MovementManager's
pcMoveTo.Motion, // MoveToFactory (same facade shape as EnsureRemoteMotionBindings);
stopCompletely: () => pcMoveTo.Motion.StopCompletely(), // MakeMoveToManager() below invokes it once, after playerHost exists
getPosition: () => new AcDream.Core.Physics.Position( // for the sticky binds.
pcMoveTo.CellId, pcMoveTo.Position, pcMoveTo.BodyOrientation), _playerController.Movement.MoveToFactory = () =>
getHeading: () => AcDream.Core.Physics.Motion.MoveToMath.HeadingFromYaw(pcMoveTo.Yaw), {
setHeading: (h, _) => pcMoveTo.Yaw = var playerMoveTo = new AcDream.Core.Physics.Motion.MoveToManager(
AcDream.Core.Physics.Motion.MoveToMath.YawFromHeading(h), pcMoveTo.Motion,
getOwnRadius: () => GetSetupCylinder(_playerServerGuid, playerEntity).Radius, stopCompletely: () => pcMoveTo.Motion.StopCompletely(),
getOwnHeight: () => GetSetupCylinder(_playerServerGuid, playerEntity).Height, getPosition: () => new AcDream.Core.Physics.Position(
contact: () => pcMoveTo.BodyInContact, pcMoveTo.CellId, pcMoveTo.Position, pcMoveTo.BodyOrientation),
isInterpolating: () => false, getHeading: () => AcDream.Core.Physics.Motion.MoveToMath.HeadingFromYaw(pcMoveTo.Yaw),
getVelocity: () => pcMoveTo.BodyVelocity, setHeading: (h, _) => pcMoveTo.Yaw =
getSelfId: () => _playerServerGuid, AcDream.Core.Physics.Motion.MoveToMath.YawFromHeading(h),
// R5-V2: retail CPhysicsObj::set_target/clear_target/quantum → the getOwnRadius: () => GetSetupCylinder(_playerServerGuid, playerEntity).Radius,
// player's TargetManager (replaces the AP-79 _playerMoveToTarget* poll). getOwnHeight: () => GetSetupCylinder(_playerServerGuid, playerEntity).Height,
setTarget: (ctx, tlid, radius, q) => playerHost.SetTarget(ctx, tlid, radius, q), contact: () => pcMoveTo.BodyInContact,
clearTarget: () => playerHost.ClearTarget(), isInterpolating: () => false,
getTargetQuantum: () => playerHost.TargetManager.GetTargetQuantum(), getVelocity: () => pcMoveTo.BodyVelocity,
setTargetQuantum: q => playerHost.TargetManager.SetTargetQuantum(q), getSelfId: () => _playerServerGuid,
curTime: () => pcMoveTo.SimTimeSeconds); // R5-V2: retail CPhysicsObj::set_target/clear_target/quantum → the
// player's TargetManager (replaces the AP-79 _playerMoveToTarget* poll).
setTarget: (ctx, tlid, radius, q) => playerHost.SetTarget(ctx, tlid, radius, q),
clearTarget: () => playerHost.ClearTarget(),
getTargetQuantum: () => playerHost.TargetManager.GetTargetQuantum(),
setTargetQuantum: q => playerHost.TargetManager.SetTargetQuantum(q),
curTime: () => pcMoveTo.SimTimeSeconds);
// AD-27 re-anchored (was the deleted AutoWalkArrived event): fire
// the deferred close-range Use/PickUp action when the moveto
// completes NATURALLY (MoveToComplete is the documented client
// seam; it never fires on CancelMoveTo, so a user-input cancel
// doesn't send the action — same contract the old "arrived"-only
// event had).
playerMoveTo.MoveToComplete = err =>
{
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
Console.WriteLine($"[autowalk-end] reason=complete err={err}");
if (err == AcDream.Core.Physics.WeenieError.None)
OnAutoWalkArrivedSendDeferredAction();
};
// R5-V3 (#171, retires TS-39 — player side): bind the sticky
// seams to the player host's PositionManager (same trio as the
// remote bind: BeginNextNode arrival StickTo @0x00529d3a,
// PerformMovement-head Unstick).
playerMoveTo.StickTo = (tlid, radius, height) =>
playerHost.PositionManager.StickTo(tlid, radius, height);
playerMoveTo.Unstick = playerHost.PositionManager.UnStick;
return playerMoveTo;
};
// R5-V2: the player's CPhysicsObj stand-in + TargetManager. Position is // R5-V2: the player's CPhysicsObj stand-in + TargetManager. Position is
// the player's WORLD position (WorldEntity.Position — what the AP-79 // the player's WORLD position (WorldEntity.Position — what the AP-79
@ -13441,52 +13516,41 @@ public sealed class GameWindow : IDisposable
curTime: () => pcMoveTo.SimTimeSeconds, curTime: () => pcMoveTo.SimTimeSeconds,
physicsTimerTime: () => pcMoveTo.SimTimeSeconds, physicsTimerTime: () => pcMoveTo.SimTimeSeconds,
getObjectA: ResolvePhysicsHost, getObjectA: ResolvePhysicsHost,
handleUpdateTarget: info => _playerController?.MoveTo?.HandleUpdateTarget(info), // Retail CPhysicsObj::HandleUpdateTarget (0x00512bc0) fan head:
interruptCurrentMovement: () => _playerController?.MoveTo?.CancelMoveTo( // MovementManager::HandleUpdateTarget (@0x00512bf0 — the facade
// relay); the host chains the PositionManager leg after it.
handleUpdateTarget: info => _playerController?.Movement.HandleUpdateTarget(info),
interruptCurrentMovement: () => _playerController?.Movement.CancelMoveTo(
AcDream.Core.Physics.WeenieError.ActionCancelled)); AcDream.Core.Physics.WeenieError.ActionCancelled));
_playerHost = playerHost; _playerHost = playerHost;
_physicsHosts[_playerServerGuid] = playerHost; _physicsHosts[_playerServerGuid] = playerHost;
// AD-27 re-anchored (was the deleted AutoWalkArrived event): fire // R5-V5: retail MakeMoveToManager (0x00524000) — constructs the
// the deferred close-range Use/PickUp action when the moveto // player MoveToManager via the factory above (playerHost now exists
// completes NATURALLY (MoveToComplete is the documented client // for the sticky binds inside it). The UM-funnel-head unstick
// seam; it never fires on CancelMoveTo, so a user-input cancel // (CPhysicsObj::unstick_from_object 0x0050eaea) binds on the interp
// doesn't send the action — same contract the old "arrived"-only // beside it, and the controller takes the PositionManager handoff it
// event had). // drives at the retail UpdatePositionInternal/UpdateObjectInternal
playerMoveTo.MoveToComplete = err => // points (AdjustOffset/UseTime).
{ var playerMovement = _playerController.Movement;
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled) playerMovement.MakeMoveToManager();
Console.WriteLine($"[autowalk-end] reason=complete err={err}");
if (err == AcDream.Core.Physics.WeenieError.None)
OnAutoWalkArrivedSendDeferredAction();
};
_playerController.MoveTo = playerMoveTo;
// R5-V3 (#171, retires TS-39 — player side): bind the sticky seams to
// the player host's PositionManager (same trio as the remote bind in
// EnsureRemoteMotionBindings: BeginNextNode arrival StickTo
// @0x00529d3a, PerformMovement-head Unstick, UM-funnel-head
// unstick_from_object 0x0050eaea) and hand the facade to the
// controller, which drives AdjustOffset/UseTime at the retail
// UpdatePositionInternal/UpdateObjectInternal points.
playerMoveTo.StickTo = (tlid, radius, height) =>
playerHost.PositionManager.StickTo(tlid, radius, height);
playerMoveTo.Unstick = playerHost.PositionManager.UnStick;
_playerController.Motion.UnstickFromObject = playerHost.PositionManager.UnStick; _playerController.Motion.UnstickFromObject = playerHost.PositionManager.UnStick;
_playerController.PositionManager = playerHost.PositionManager; _playerController.PositionManager = playerHost.PositionManager;
// TS-36 RETIRED: the interp's interrupt seam is retail's // TS-36 RETIRED: the interp's interrupt seam is retail's
// interrupt_current_movement → MovementManager::CancelMoveTo(0x36) // interrupt_current_movement → MovementManager::CancelMoveTo(0x36)
// chain (raw 278189-278200). Every DoMotion/StopMotion/ // chain (raw 278189-278200) — since R5-V5 the literal facade relay
// StopCompletely/jump/set_hold_run cancel site now genuinely // (0x005241b0). Every DoMotion/StopMotion/StopCompletely/jump/
// cancels a running moveto (V2's reentrancy tests prove the chain // set_hold_run cancel site now genuinely cancels a running moveto
// is inert-safe). // (V2's reentrancy tests prove the chain is inert-safe). Captures
// THIS controller's facade (not the _playerController field) so a
// Tab-toggle's stale interp keeps cancelling its own manager.
_playerController.Motion.InterruptCurrentMovement = () => _playerController.Motion.InterruptCurrentMovement = () =>
{ {
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled
&& playerMoveTo.IsMovingTo()) && playerMovement.IsMovingTo())
Console.WriteLine("[autowalk-end] reason=interrupt"); Console.WriteLine("[autowalk-end] reason=interrupt");
playerMoveTo.CancelMoveTo(AcDream.Core.Physics.WeenieError.ActionCancelled); playerMovement.CancelMoveTo(AcDream.Core.Physics.WeenieError.ActionCancelled);
}; };
// K-fix7 (2026-04-26): if PlayerDescription already arrived, the // K-fix7 (2026-04-26): if PlayerDescription already arrived, the

View file

@ -0,0 +1,171 @@
using System;
namespace AcDream.Core.Physics.Motion;
/// <summary>
/// R5-V5 — retail <c>MovementManager</c> (acclient.h <c>/* 3463 */</c>, 0x10
/// bytes / four pointers): the ONE per-entity owner of the movement pipeline's
/// two managers. Decomp extract:
/// <c>docs/research/2026-07-03-r5-managers/r5-movementmanager-decomp.md</c>.
///
/// <code>
/// struct MovementManager {
/// CMotionInterp *motion_interpreter; // +0x0 → Minterp
/// MoveToManager *moveto_manager; // +0x4 → MoveTo (lazy)
/// CPhysicsObj *physics_obj; // +0x8 ┐ carried by the children +
/// CWeenieObject *weenie_obj; // +0xc ┘ the MoveToFactory closure
/// };
/// </code>
///
/// <para><b>Construction mapping.</b> Retail lazily creates BOTH children
/// (<c>CMotionInterp::Create</c> + <c>enter_default_state</c> at every entry
/// point; <c>MoveToManager::Create</c> via <see cref="MakeMoveToManager"/>).
/// acdream constructs the interp eagerly at entity construction
/// (<c>RemoteMotion</c> / <c>PlayerMovementController</c> ctors — the lazy
/// window is never observable; register TS-38 already covers the
/// <c>Initted</c> side of this) and hands it to this ctor. The moveto side
/// keeps retail's lazy <see cref="MakeMoveToManager"/> mechanism: retail
/// constructs from the <c>physics_obj</c>/<c>weenie_obj</c> backpointers;
/// acdream's <see cref="MoveToFactory"/> closure is the stand-in for those
/// two fields, carrying the full seam wiring (set once at the bind site —
/// <c>EnsureRemoteMotionBindings</c> / <c>EnterPlayerModeNow</c> / the chase
/// harness — which then calls <see cref="MakeMoveToManager"/> immediately,
/// preserving the pre-facade eager timing; the ctor is side-effect-free so
/// the timing is unobservable either way).</para>
///
/// <para><b>Deliberately NOT absorbed here</b> (the R5-V5 slice keeps these
/// at their current owners): <c>unpack_movement</c> 0x00524440 ≡ the
/// GameWindow UM path + <c>RouteServerMoveTo</c> (Core.Net wire types stay
/// out of Core.Physics); <c>move_to_interpreted_state</c> 0x00524170 ≡ the
/// funnel's <c>MotionInterpreter.MoveToInterpretedState</c> call sites;
/// <c>MotionDone</c> 0x005242d0 ≡ the sequencer's <c>MotionDoneTarget</c>
/// seam (register AD-36); <c>LeaveGround</c> 0x00524320's moveto half is a
/// COMDAT no-op (see <c>PlayerMovementController</c>'s landing comment) so
/// call sites keep invoking <c>Minterp.LeaveGround()</c> directly;
/// <c>EnterDefaultState</c>/<c>HandleEnterWorld</c>/<c>ReportExhaustion</c>/
/// <c>SetWeenieObject</c>/<c>Destroy</c> have no acdream caller yet —
/// <c>get_minterp</c> 0x005242a0 ≡ the <see cref="Minterp"/> property.</para>
///
/// <para><b>PerformMovement's <c>set_active(1)</c> head</b>
/// (0x005240d9) is not re-asserted here: acdream bodies assert the Active
/// transient bit at spawn (<c>RemoteMotion</c> ctor) and the pre-facade
/// route never re-asserted it — status quo preserved (zero-behavior-change
/// slice), not a new deviation.</para>
/// </summary>
public sealed class MovementManager
{
/// <summary>Retail <c>motion_interpreter</c> (+0x0). Always present in
/// acdream (eager construction — see the class doc); direct child access
/// for interp-specific ops mirrors retail's <c>get_minterp</c>
/// (0x005242a0) callers.</summary>
public MotionInterpreter Minterp { get; }
/// <summary>Retail <c>moveto_manager</c> (+0x4) — null until
/// <see cref="MakeMoveToManager"/> (or a type-6..9
/// <see cref="PerformMovement"/>) creates it.</summary>
public MoveToManager? MoveTo { get; private set; }
/// <summary>The acdream stand-in for retail's <c>physics_obj</c>/
/// <c>weenie_obj</c> backpointers (+0x8/+0xc): the creation recipe
/// <see cref="MakeMoveToManager"/> invokes. Set exactly once at the bind
/// site; the closure carries the MoveToManager's seam wiring (and the
/// StickTo/Unstick/MoveToComplete binds) that retail reads off
/// <c>CPhysicsObj</c>.</summary>
public Func<MoveToManager>? MoveToFactory { get; set; }
public MovementManager(MotionInterpreter minterp)
{
Minterp = minterp ?? throw new ArgumentNullException(nameof(minterp));
}
/// <summary>
/// Retail <c>MovementManager::MakeMoveToManager</c> (0x00524000):
/// lazy-construct <see cref="MoveTo"/> if null; no-op if already present.
/// No-op (instead of retail's unconditional create) when no factory has
/// been bound yet — acdream's seams arrive from the bind site, and every
/// consumer path runs after binding.
/// </summary>
public void MakeMoveToManager()
{
if (MoveTo is null && MoveToFactory is not null)
MoveTo = MoveToFactory();
}
/// <summary>
/// Retail <c>MovementManager::PerformMovement</c> (0x005240d0): the
/// type-1..9 two-way dispatch. <c>(type - 1) &gt; 8</c> (type 0
/// underflows unsigned) → 0x47; types 1-5 → <c>CMotionInterp::
/// PerformMovement</c> (return propagated); types 6-9 →
/// <see cref="MakeMoveToManager"/> + <c>MoveToManager::PerformMovement</c>
/// whose return is NOT propagated (@0052414f <c>return 0</c>) — the
/// facade reports <see cref="WeenieError.None"/> for that path.
/// </summary>
public WeenieError PerformMovement(MovementStruct mvs)
{
switch (mvs.Type)
{
case MovementType.RawCommand:
case MovementType.InterpretedCommand:
case MovementType.StopRawCommand:
case MovementType.StopInterpretedCommand:
case MovementType.StopCompletely:
return Minterp.PerformMovement(mvs);
case MovementType.MoveToObject:
case MovementType.MoveToPosition:
case MovementType.TurnToObject:
case MovementType.TurnToHeading:
MakeMoveToManager();
if (MoveTo is null)
// acdream-only guard: a type-6..9 event before the bind
// site set MoveToFactory (unreachable in production —
// EnsureRemoteMotionBindings / EnterPlayerModeNow bind
// before any route can fire). Retail would Create here.
return WeenieError.GeneralMovementFailure;
MoveTo.PerformMovement(mvs);
return WeenieError.None;
default:
return WeenieError.GeneralMovementFailure; // 0x47
}
}
/// <summary>Retail <c>MovementManager::UseTime</c> (0x005242f0): relay
/// to <see cref="MoveTo"/> ONLY (does not touch the interp, does not
/// lazy-create); no-op while null.</summary>
public void UseTime() => MoveTo?.UseTime();
/// <summary>Retail <c>MovementManager::HitGround</c> (0x00524300): fan
/// to BOTH children if present — <c>CMotionInterp::HitGround</c> FIRST
/// (the falling-pose exit re-apply), then <c>MoveToManager::HitGround</c>
/// (re-arms a moveto suspended by the airborne UseTime contact
/// gate).</summary>
public void HitGround()
{
Minterp.HitGround();
MoveTo?.HitGround();
}
/// <summary>Retail <c>MovementManager::HandleExitWorld</c> (0x00524350):
/// interp ONLY (drains <c>pending_motions</c>); does not touch
/// <see cref="MoveTo"/>.</summary>
public void HandleExitWorld() => Minterp.HandleExitWorld();
/// <summary>Retail <c>MovementManager::CancelMoveTo</c> (0x005241b0):
/// relay to <see cref="MoveTo"/>; no-op while null. The retail
/// <c>interrupt_current_movement → MovementManager::CancelMoveTo(0x36)</c>
/// chain lands here.</summary>
public void CancelMoveTo(WeenieError error) => MoveTo?.CancelMoveTo(error);
/// <summary>Retail <c>MovementManager::HandleUpdateTarget</c>
/// (0x00524790): relay to <see cref="MoveTo"/>; no-op while null. The
/// <c>CPhysicsObj::HandleUpdateTarget</c> fan (0x00512bc0) calls this
/// leg first, then the PositionManager's (host order —
/// <c>EntityPhysicsHost.HandleUpdateTarget</c>).</summary>
public void HandleUpdateTarget(TargetInfo info) => MoveTo?.HandleUpdateTarget(info);
/// <summary>Retail <c>MovementManager::IsMovingTo</c> (0x00524260):
/// true iff <see cref="MoveTo"/> exists AND reports an armed
/// move.</summary>
public bool IsMovingTo() => MoveTo?.IsMovingTo() ?? false;
}

View file

@ -0,0 +1,341 @@
using System.Numerics;
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
using Xunit;
namespace AcDream.Core.Tests.Physics.Motion;
/// <summary>
/// R5-V5 — <see cref="MovementManager"/> facade conformance (retail struct
/// acclient.h /* 3463 */; methods 0x00524000-0x00524790, decomp extract
/// <c>docs/research/2026-07-03-r5-managers/r5-movementmanager-decomp.md</c>).
/// The facade is pure relay/ownership — these tests pin the retail call
/// shapes: which child each method touches, the lazy MakeMoveToManager
/// create, the PerformMovement type dispatch, and null-tolerance before the
/// moveto manager exists. Behavior of the children themselves is covered by
/// the MotionInterpreter / MoveToManager suites (UNMODIFIED by R5-V5).
/// </summary>
public sealed class MovementManagerTests
{
/// <summary>Non-creature weenie: silences the MotionInterpreter's
/// HitGround/LeaveGround creature gates (retail IsCreature vtable
/// +0x2c) so a test can isolate the facade's MOVETO relay leg.</summary>
private sealed class NonCreatureWeenie : IWeenieObject
{
public bool InqJumpVelocity(float extent, out float vz) { vz = 0f; return false; }
public bool InqRunRate(out float rate) { rate = 1f; return false; }
public bool CanJump(float extent) => false;
bool IWeenieObject.IsCreature() => false;
}
/// <summary>Facade over the shared MoveToManagerHarness: the harness's
/// REAL MotionInterpreter is the minterp child; the harness's REAL
/// seam-scripted MoveToManager arrives via <see cref="MovementManager.MoveToFactory"/>
/// (the acdream stand-in for retail's physics_obj/weenie_obj
/// backpointers that MakeMoveToManager constructs from).</summary>
private static (MovementManager Mm, MoveToManagerHarness H, int[] FactoryCalls) MakeFacade()
{
var h = new MoveToManagerHarness();
var factoryCalls = new int[1];
var mm = new MovementManager(h.Interp)
{
MoveToFactory = () => { factoryCalls[0]++; return h.Manager; },
};
return (mm, h, factoryCalls);
}
// ── MakeMoveToManager — 0x00524000 ──────────────────────────────────────
[Fact]
public void MakeMoveToManager_CreatesViaFactory_ExactlyOnce()
{
var (mm, h, calls) = MakeFacade();
Assert.Null(mm.MoveTo);
mm.MakeMoveToManager();
Assert.Same(h.Manager, mm.MoveTo);
Assert.Equal(1, calls[0]);
// Retail: no-op if already present.
mm.MakeMoveToManager();
Assert.Same(h.Manager, mm.MoveTo);
Assert.Equal(1, calls[0]);
}
[Fact]
public void MakeMoveToManager_WithoutFactory_IsANoOp()
{
var mm = new MovementManager(new MotionInterpreter());
mm.MakeMoveToManager();
Assert.Null(mm.MoveTo);
}
// ── PerformMovement — 0x005240d0 (the type-1..9 two-way dispatch) ───────
[Fact]
public void PerformMovement_InterpTypes_RouteToMinterp_NotMoveTo()
{
var (mm, h, calls) = MakeFacade();
var result = mm.PerformMovement(new MovementStruct
{
Type = MovementType.InterpretedCommand,
Motion = MotionCommand.WalkForward,
Speed = 1f,
ModifyInterpretedState = true,
});
Assert.Equal(WeenieError.None, result);
Assert.Equal(MotionCommand.WalkForward, h.Interp.InterpretedState.ForwardCommand);
// Types 1-5 never touch the moveto side (jump table 0x0052415c).
Assert.Equal(0, calls[0]);
Assert.Null(mm.MoveTo);
}
[Fact]
public void PerformMovement_MoveToTypes_LazyCreate_AndRouteToMoveTo()
{
var (mm, h, calls) = MakeFacade();
var result = mm.PerformMovement(new MovementStruct
{
Type = MovementType.MoveToPosition,
Pos = new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity),
Params = new MovementParameters(),
});
// Retail cases 5-8 (types 6-9): MakeMoveToManager first, delegate,
// and the MoveToManager path's return is NOT propagated (@0052414f
// `return 0`) — the facade reports None regardless.
Assert.Equal(WeenieError.None, result);
Assert.Equal(1, calls[0]);
Assert.Equal(MovementType.MoveToPosition, h.Manager.MovementTypeState);
}
[Fact]
public void PerformMovement_InvalidAndOutOfRangeTypes_Fail0x47()
{
var (mm, _, calls) = MakeFacade();
// Retail head: (type - 1) > 8 → 0x47. Type 0 underflows unsigned →
// always > 8; anything above 9 fails the same check.
Assert.Equal(WeenieError.GeneralMovementFailure,
mm.PerformMovement(new MovementStruct { Type = MovementType.Invalid }));
Assert.Equal(WeenieError.GeneralMovementFailure,
mm.PerformMovement(new MovementStruct { Type = (MovementType)10 }));
Assert.Equal(0, calls[0]);
}
[Fact]
public void PerformMovement_MoveToType_WithoutFactory_Fails0x47()
{
// acdream-only guard for the unreachable-in-production ordering
// (a type-6..9 event before the bind sites set MoveToFactory):
// retail would MoveToManager::Create here; without a factory the
// facade reports the same 0x47 the range check uses.
var mm = new MovementManager(new MotionInterpreter());
Assert.Equal(WeenieError.GeneralMovementFailure,
mm.PerformMovement(new MovementStruct { Type = MovementType.TurnToHeading }));
}
// ── UseTime — 0x005242f0 (moveto only; never lazy-creates) ──────────────
[Fact]
public void UseTime_BeforeMoveToExists_IsANoOp_AndDoesNotCreate()
{
var (mm, _, calls) = MakeFacade();
mm.UseTime();
Assert.Equal(0, calls[0]);
Assert.Null(mm.MoveTo);
}
[Fact]
public void UseTime_RelaysToMoveTo()
{
// The MoveToManagerUseTimeGateTests arrival shape, driven through
// the facade: grounded, facing the target, arrived — one UseTime
// completes the move.
var (mm, h, _) = MakeFacade();
h.ContactValue = true;
h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity);
h.Heading = 90f;
mm.PerformMovement(new MovementStruct
{
Type = MovementType.MoveToPosition,
Pos = new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity),
Params = new MovementParameters { DistanceToObject = 0.6f, UseSpheres = false },
});
h.DrainPendingMotions();
h.WorldPosition = new Position(1u, new Vector3(19.7f, 0f, 0f), Quaternion.Identity);
h.Advance(2.0);
mm.UseTime();
Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState);
}
// ── HitGround — 0x00524300 (minterp FIRST, then moveto) ────────────────
[Fact]
public void HitGround_RelaysToMinterp_AndToleratesNullMoveTo()
{
var (mm, h, _) = MakeFacade();
h.Body.State |= PhysicsStateFlags.Gravity; // CMotionInterp::HitGround gates on state & 0x400
bool minterpHit = false;
h.Interp.RemoveLinkAnimations = () => minterpHit = true;
mm.HitGround(); // MoveTo still null — retail's if-present guard
Assert.True(minterpHit);
}
[Fact]
public void HitGround_RelaysMinterpFirst_ThenMoveTo()
{
var (mm, h, _) = MakeFacade();
h.Body.State |= PhysicsStateFlags.Gravity;
h.ContactValue = true;
h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity);
h.Heading = 90f;
mm.PerformMovement(new MovementStruct
{
Type = MovementType.MoveToPosition,
Pos = new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity),
Params = new MovementParameters(),
});
h.DrainPendingMotions();
// Order pin: the FIRST RemoveLinkAnimations firing belongs to the
// minterp leg (CMotionInterp::HitGround invokes it before any
// dispatch), at which point the moveto leg's BeginNextNode
// re-dispatch has NOT happened yet — the pending queue is still
// drained. Later firings (the moveto dispatch's own TS-40
// detached-strip at the DoInterpretedMotion tail runs AFTER its
// enqueue) must not overwrite the recording — ??= keeps the first.
bool? queueEmptyAtMinterpLeg = null;
h.Interp.RemoveLinkAnimations =
() => queueEmptyAtMinterpLeg ??= !h.Interp.MotionsPending();
mm.HitGround();
Assert.True(queueEmptyAtMinterpLeg); // minterp leg ran first
Assert.True(h.Interp.MotionsPending()); // a re-dispatch landed after it
}
[Fact]
public void HitGround_ReachesMoveTo_WhenMinterpLegIsGated()
{
// Isolate the MOVETO leg: a non-creature weenie makes
// CMotionInterp::HitGround a retail no-op (IsCreature gate), so any
// re-dispatched pending motion can only come from
// MoveToManager::HitGround → BeginNextNode.
var (mm, h, _) = MakeFacade();
h.Interp.WeenieObj = new NonCreatureWeenie();
h.ContactValue = true;
h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity);
h.Heading = 90f;
mm.PerformMovement(new MovementStruct
{
Type = MovementType.MoveToPosition,
Pos = new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity),
Params = new MovementParameters(),
});
h.DrainPendingMotions();
mm.HitGround();
Assert.True(h.Interp.MotionsPending());
}
// ── HandleExitWorld — 0x00524350 (minterp ONLY) ─────────────────────────
[Fact]
public void HandleExitWorld_DrainsMinterp_AndDoesNotTouchMoveTo()
{
var (mm, h, _) = MakeFacade();
h.ContactValue = true;
h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity);
h.Heading = 90f;
mm.PerformMovement(new MovementStruct
{
Type = MovementType.MoveToPosition,
Pos = new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity),
Params = new MovementParameters(),
});
Assert.True(h.Interp.MotionsPending()); // the arm's dispatch is queued
mm.HandleExitWorld();
Assert.False(h.Interp.MotionsPending());
// Retail HandleExitWorld does NOT touch moveto_manager — the armed
// move survives (its teardown is CancelMoveTo / exit-world at the
// CPhysicsObj layer, not here).
Assert.Equal(MovementType.MoveToPosition, h.Manager.MovementTypeState);
}
// ── CancelMoveTo — 0x005241b0 / IsMovingTo — 0x00524260 ────────────────
[Fact]
public void CancelMoveTo_NullTolerant_AndRelaysToMoveTo()
{
var (mm, h, _) = MakeFacade();
mm.CancelMoveTo(WeenieError.ActionCancelled); // no moveto yet — no throw
h.ContactValue = true;
h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity);
mm.PerformMovement(new MovementStruct
{
Type = MovementType.MoveToPosition,
Pos = new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity),
Params = new MovementParameters(),
});
Assert.True(mm.IsMovingTo());
mm.CancelMoveTo(WeenieError.ActionCancelled);
Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState);
Assert.False(mm.IsMovingTo());
Assert.True(h.StopCompletelyCalls > 0);
}
[Fact]
public void IsMovingTo_FalseBeforeMoveToExists()
{
var (mm, _, _) = MakeFacade();
Assert.False(mm.IsMovingTo());
}
// ── HandleUpdateTarget — 0x00524790 (→ moveto) ──────────────────────────
[Fact]
public void HandleUpdateTarget_NullTolerant_AndFeedsMoveToDeferredStart()
{
var (mm, h, _) = MakeFacade();
var info = new TargetInfo
{
ObjectId = 0x5000AAAAu,
Status = TargetStatus.Ok,
TargetPosition = new Position(1u, new Vector3(10f, 0f, 0f), Quaternion.Identity),
InterpolatedPosition = new Position(1u, new Vector3(10f, 0f, 0f), Quaternion.Identity),
};
mm.HandleUpdateTarget(info); // no moveto yet — no throw
// The V2 "uninitialized type-6 stall": MoveToObject defers its node
// build to the FIRST HandleUpdateTarget delivery.
h.ContactValue = true;
h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity);
mm.PerformMovement(new MovementStruct
{
Type = MovementType.MoveToObject,
ObjectId = 0x5000AAAAu,
TopLevelId = 0x5000AAAAu,
Pos = new Position(1u, new Vector3(10f, 0f, 0f), Quaternion.Identity),
Params = new MovementParameters(),
});
Assert.False(h.Manager.Initialized);
mm.HandleUpdateTarget(info);
Assert.True(h.Manager.Initialized);
}
}

View file

@ -99,7 +99,16 @@ internal sealed class RemoteChaseHarness
public readonly MotionInterpreter Interp; public readonly MotionInterpreter Interp;
public readonly AnimationSequencer Seq; public readonly AnimationSequencer Seq;
public readonly MotionTableDispatchSink Sink; public readonly MotionTableDispatchSink Sink;
public readonly MoveToManager Mgr;
/// <summary>R5-V5: GameWindow's RemoteMotion.Movement twin — the ONE
/// per-entity MovementManager facade owning Interp + the MoveToManager
/// (retail CPhysicsObj::movement_manager).</summary>
public readonly MovementManager Movement;
/// <summary>The moveto child view (RemoteMotion.MoveTo twin) — non-null
/// after the ctor's MakeMoveToManager, kept so test bodies read
/// unchanged.</summary>
public MoveToManager Mgr => Movement.MoveTo!;
/// <summary>R5-V3 (#171): the creature's PositionManager facade — the /// <summary>R5-V3 (#171): the creature's PositionManager facade — the
/// EntityPhysicsHost-owned twin (GameWindow binds MoveToManager.StickTo/ /// EntityPhysicsHost-owned twin (GameWindow binds MoveToManager.StickTo/
@ -169,44 +178,60 @@ internal sealed class RemoteChaseHarness
Interp.InitializeMotionTables = () => Seq.Manager.InitializeState(); Interp.InitializeMotionTables = () => Seq.Manager.InitializeState();
Interp.CheckForCompletedMotions = Seq.Manager.CheckForCompletedMotions; Interp.CheckForCompletedMotions = Seq.Manager.CheckForCompletedMotions;
Mgr = new MoveToManager( // ── R5-V5: the MovementManager facade owns Interp + the moveto —
Interp, // GameWindow's RemoteMotion ctor + EnsureRemoteMotionBindings
stopCompletely: () => Interp.StopCompletely(), // factory shape verbatim (sticky binds inside the factory;
getPosition: () => new CorePosition(1u, Body.Position, Body.Orientation), // MakeMoveToManager after the host/Pm exist). ──
getHeading: () => MoveToMath.GetHeading(Body.Orientation), Movement = new MovementManager(Interp)
setHeading: (h, _) => Body.Orientation = {
MoveToMath.SetHeading(Body.Orientation, h), MoveToFactory = () =>
getOwnRadius: () => OwnRadius,
getOwnHeight: () => 1f,
contact: () => Body.OnWalkable,
isInterpolating: () => false,
getVelocity: () => Body.Velocity,
getSelfId: () => CreatureGuid,
setTarget: (ctx, tlid, radius, q) =>
{ {
_targetArmed = tlid == PlayerGuid; var mtm = new MoveToManager(
// TargetManager delivers the FIRST info synchronously on Interp,
// SetTarget (live log: HandleUpdateTarget printed directly stopCompletely: () => Interp.StopCompletely(),
// after the arm, same network phase). getPosition: () => new CorePosition(1u, Body.Position, Body.Orientation),
DeliverTargetInfo(); getHeading: () => MoveToMath.GetHeading(Body.Orientation),
setHeading: (h, _) => Body.Orientation =
MoveToMath.SetHeading(Body.Orientation, h),
getOwnRadius: () => OwnRadius,
getOwnHeight: () => 1f,
contact: () => Body.OnWalkable,
isInterpolating: () => false,
getVelocity: () => Body.Velocity,
getSelfId: () => CreatureGuid,
setTarget: (ctx, tlid, radius, q) =>
{
_targetArmed = tlid == PlayerGuid;
// TargetManager delivers the FIRST info synchronously on
// SetTarget (live log: HandleUpdateTarget printed directly
// after the arm, same network phase).
DeliverTargetInfo();
},
clearTarget: () => _targetArmed = false,
getTargetQuantum: () => _quantum,
setTargetQuantum: q => _quantum = q,
curTime: () => Now);
// R5-V3 (#171) sticky seam binds (BeginNextNode arrival
// StickTo @0x00529d3a, PerformMovement-head Unstick).
mtm.StickTo = (tlid, radius, height) => Pm.StickTo(tlid, radius, height);
mtm.Unstick = Pm.UnStick;
return mtm;
}, },
clearTarget: () => _targetArmed = false, };
getTargetQuantum: () => _quantum,
setTargetQuantum: q => _quantum = q,
curTime: () => Now);
// TS-36: interrupt_current_movement → MovementManager::CancelMoveTo
// (the facade relay 0x005241b0) — EnsureRemoteMotionBindings twin.
Interp.InterruptCurrentMovement = Interp.InterruptCurrentMovement =
() => Mgr.CancelMoveTo(WeenieError.ActionCancelled); () => Movement.CancelMoveTo(WeenieError.ActionCancelled);
// ── R5-V3 (#171): the PositionManager/sticky wiring — GameWindow's // ── R5-V3 (#171): the PositionManager/sticky wiring — GameWindow's
// V3 additions verbatim: host-owned facade + the three seam binds // V3 additions verbatim: host-owned facade + the UM-funnel-head
// (BeginNextNode arrival StickTo, PerformMovement-head Unstick, // unstick_from_object bind; then MakeMoveToManager (0x00524000)
// UM-funnel-head unstick_from_object). ── // invokes the factory above, mirroring the production bind order. ──
_playerHost = new TargetHost(this); _playerHost = new TargetHost(this);
_creatureHost = new CreatureHost(this); _creatureHost = new CreatureHost(this);
Pm = new PositionManager(_creatureHost); Pm = new PositionManager(_creatureHost);
Mgr.StickTo = (tlid, radius, height) => Pm.StickTo(tlid, radius, height); Movement.MakeMoveToManager();
Mgr.Unstick = Pm.UnStick;
Interp.UnstickFromObject = Pm.UnStick; Interp.UnstickFromObject = Pm.UnStick;
// ── The anim-loop MotionDone binding (GameWindow.cs:10266) ── // ── The anim-loop MotionDone binding (GameWindow.cs:10266) ──
@ -295,7 +320,9 @@ internal sealed class RemoteChaseHarness
Radius = targetRadius, Radius = targetRadius,
Height = targetHeight, Height = targetHeight,
}; };
Mgr.PerformMovement(ms); // R5-V5: RouteServerMoveTo twin — through the facade
// (MovementManager::PerformMovement 0x005240d0).
Movement.PerformMovement(ms);
} }
private void DeliverTargetInfo() private void DeliverTargetInfo()
@ -311,9 +338,10 @@ internal sealed class RemoteChaseHarness
InterpolatedPosition = pos, InterpolatedPosition = pos,
}; };
// R5-V3 fan order (EntityPhysicsHost.HandleUpdateTarget — retail // R5-V3 fan order (EntityPhysicsHost.HandleUpdateTarget — retail
// CPhysicsObj::HandleUpdateTarget 0x00512bc0): MoveToManager first, // CPhysicsObj::HandleUpdateTarget 0x00512bc0): the MovementManager
// then PositionManager (the sticky consumer). // relay (@0x00512bf0 → moveto) first, then PositionManager (the
Mgr.HandleUpdateTarget(info); // sticky consumer).
Movement.HandleUpdateTarget(info);
Pm.HandleUpdateTarget(info); Pm.HandleUpdateTarget(info);
} }
@ -338,12 +366,12 @@ internal sealed class RemoteChaseHarness
public void HandleUpdateTarget(TargetInfo info) public void HandleUpdateTarget(TargetInfo info)
{ {
_h.Mgr.HandleUpdateTarget(info); _h.Movement.HandleUpdateTarget(info);
_h.Pm.HandleUpdateTarget(info); _h.Pm.HandleUpdateTarget(info);
} }
public void InterruptCurrentMovement() public void InterruptCurrentMovement()
=> _h.Mgr.CancelMoveTo(WeenieError.ActionCancelled); => _h.Movement.CancelMoveTo(WeenieError.ActionCancelled);
public void SetTarget(uint contextId, uint objectId, float radius, double quantum) public void SetTarget(uint contextId, uint objectId, float radius, double quantum)
{ {
@ -399,8 +427,9 @@ internal sealed class RemoteChaseHarness
if (_targetArmed && Now - _lastDeliveryTime >= _quantum) if (_targetArmed && Now - _lastDeliveryTime >= _quantum)
DeliverTargetInfo(); DeliverTargetInfo();
// 2. MoveToManager drive (TickRemoteMoveTo, GameWindow.cs:9994). // 2. MovementManager drive (TickRemoteMoveTo — the R5-V5 facade
Mgr.UseTime(); // relay, MovementManager::UseTime 0x005242f0).
Movement.UseTime();
// 3. get_state_velocity → body velocity (the d2ccc80e refresh, // 3. get_state_velocity → body velocity (the d2ccc80e refresh,
// GameWindow.cs:10024). // GameWindow.cs:10024).