diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index bbcebbdb..24ff2e1a 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -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-34 | Retail's intrusive `DLListBase`/`DLListData` lists are managed `LinkedList`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-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-37 | RETIRED misattribution note (not a live divergence — kept here as the historical record R3-W3 closes): the S2a port had `contact_allows_move` (0x00528240) arm `StandingLongJump` as a side effect, explicitly flagged "PRE-EXISTING acdream side effect (not part of 0x00528240)". R3-W3 deletes that side effect; `ChargeJump` (0x005281c0) is now the ONLY arming site, matching retail exactly. No further action — recorded per the register's retire-in-same-commit rule | `src/AcDream.Core/Physics/MotionInterpreter.cs` (`contact_allows_move`, `ChargeJump`) | N/A — retired | N/A — retired | `CMotionInterp::charge_jump` 0x005281c0 @305448 | | TS-38 | `MotionInterpreter.Initted` defaults to `true` in both constructors, not retail's `false` — retail's `CMotionInterp` is never observed pre-`enter_default_state` (every real construction path calls it before exposing the interpreter); acdream's constructors are used directly by ~40 pre-existing tests and both App call sites as complete, immediately-usable objects with no separate "enter default state" step | `src/AcDream.Core/Physics/MotionInterpreter.cs` (`Initted` property + both constructors) | Defaulting `true` is the C# equivalent of "the constructor already did what `enter_default_state` would have done to this flag" — `EnterDefaultState()` remains available, verbatim, for the REST of retail's reset semantics (state defaults, sentinel enqueue, `LeaveGround` tail) when a caller wants them | None observed: no code path needs `apply_current_movement`/`ReportExhaustion` to no-op before an explicit `EnterDefaultState()` call, since nothing constructs a `MotionInterpreter` and defers initialization today. If a future caller DOES need staged construction (build now, `EnterDefaultState()` later), it must explicitly set `Initted = false` first | `CMotionInterp::enter_default_state` 0x00528c80 @306124 sets `initted = 1`; retire if/when construction is staged through `EnterDefaultState()` uniformly | -| TS-41 | 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-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-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` → `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-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 | diff --git a/src/AcDream.App/Input/PlayerMovementController.cs b/src/AcDream.App/Input/PlayerMovementController.cs index 872bb01c..8f07011e 100644 --- a/src/AcDream.App/Input/PlayerMovementController.cs +++ b/src/AcDream.App/Input/PlayerMovementController.cs @@ -268,23 +268,46 @@ public sealed class PlayerMovementController // MoveToManager remotes use (R4-V4), bound below by GameWindow's // EnterPlayerModeNow beside the R3-W6 DefaultSink bind. + /// + /// R5-V5: retail CPhysicsObj::movement_manager (acclient.h + /// /* 3463 */) — the ONE owner of this controller's + /// + pair. Constructed in the + /// ctor around the interp; the MoveToManager side arrives via + /// MoveToFactory + MakeMoveToManager() in + /// GameWindow.EnterPlayerModeNow (the same facade shape + /// EnsureRemoteMotionBindings gives remotes). + /// ticks UseTime() (0x005242f0) at the slot the deleted + /// DriveServerAutoWalk occupied and relays HitGround() + /// (0x00524300, minterp first then moveto). + /// + public AcDream.Core.Physics.Motion.MovementManager Movement { get; } + /// /// R4-V5: the local player's verbatim retail MoveToManager /// (decomp 0x00529010-0x0052a987), constructed + seam-bound by /// GameWindow.EnterPlayerModeNow against this controller's /// /body/Yaw (the same wiring shape /// EnsureRemoteMotionBindings uses for remotes). GameWindow - /// routes inbound mt 6-9 movement events to - /// ; - /// ticks UseTime() at the slot the deleted - /// DriveServerAutoWalk occupied and relays HitGround() - /// (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) → + /// routes inbound mt 6-9 movement events through + /// . + /// User input cancels a moveto through the retail chain: key edge → + /// DoMotion (ctor-default params, CancelMoveTo bit set) → /// → /// CancelMoveTo(ActionCancelled) (register TS-36 retired). + /// R5-V5: a view of 's moveto child; the setter is + /// sugar over the facade's factory path (kept for the + /// PlayerMoveToCutoverTests rig and any pre-facade bind shape). /// - 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(); + } + } /// /// R5-V3 (#171): the player's PositionManager facade (retail @@ -327,6 +350,10 @@ public sealed class PlayerMovementController int jumpSkill = int.TryParse(Environment.GetEnvironmentVariable("ACDREAM_JUMP_SKILL"), out var jsv) ? jsv : 300; _weenie = new PlayerWeenie(runSkill: runSkill, jumpSkill: jumpSkill); _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 — // movement_is_autonomous true so apply_current_movement's dual // 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 // invariant, now by construction). Provisional tick placement until // R6 ports retail's UpdateObjectInternal ordering (r4-port-plan.md - // §3 placement decision). - MoveTo?.UseTime(); + // §3 placement decision). R5-V5: the facade relay + // (MovementManager::UseTime 0x005242f0 — moveto side only). + Movement.UseTime(); // R4-V5 wedge fix (2026-07-03 live door bug), retail's per-tick // completion slot: CPartArray::HandleMovement — a tailcall chain to @@ -1033,13 +1061,13 @@ public sealed class PlayerMovementController if (wasAirborne) { - _motion.HitGround(); - // R4-V5: retail order — minterp first, then moveto - // (MovementManager::HitGround 0x00524300, decomp §2d); - // re-arms a moveto suspended by the airborne UseTime - // contact gate. LeaveGround has NO moveto side (COMDAT - // no-op, §2e) — do not add one. - MoveTo?.HitGround(); + // R4-V5 → R5-V5: retail order — minterp first, then + // moveto (MovementManager::HitGround 0x00524300, decomp + // §2d — now the literal facade relay); re-arms a moveto + // suspended by the airborne UseTime contact gate. + // LeaveGround has NO moveto side (COMDAT no-op, §2e) — + // do not add one. + Movement.HitGround(); justLanded = true; } } diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 04bc8768..6b59c300 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -411,7 +411,18 @@ public sealed class GameWindow : IDisposable internal sealed class RemoteMotion // internal: the R2-Q5 sink callbacks (ObservedOmega seam) capture it { public AcDream.Core.Physics.PhysicsBody Body; - public AcDream.Core.Physics.MotionInterpreter Motion; + + /// R5-V5: retail CPhysicsObj::movement_manager — the + /// ONE per-entity owner of the interp + moveto pair (acclient.h + /// /* 3463 */). Constructed here with the interp; the + /// MoveToManager side arrives via MoveToFactory + + /// MakeMoveToManager() in EnsureRemoteMotionBindings. + /// / below are views of its + /// children (retail get_minterp-style access), kept so the + /// dozens of existing call sites read unchanged. + public AcDream.Core.Physics.Motion.MovementManager Movement; + + public AcDream.Core.Physics.MotionInterpreter Motion => Movement.Minterp; /// R3-W4: the persistent per-remote dispatch sink (created /// once by EnsureRemoteMotionBindings; also bound as /// Motion.DefaultSink so LeaveGround/HitGround re-applies dispatch @@ -432,8 +443,9 @@ public sealed class GameWindow : IDisposable /// R4-V4: the entity's verbatim retail MoveToManager /// (server-directed movement), constructed + seam-bound by - /// EnsureRemoteMotionBindings beside the R2-Q5/R3 binds. - public AcDream.Core.Physics.Motion.MoveToManager? MoveTo; + /// EnsureRemoteMotionBindings beside the R2-Q5/R3 binds. R5-V5: + /// owned by ; this is the child view. + public AcDream.Core.Physics.Motion.MoveToManager? MoveTo => Movement.MoveTo; // R5-V2: the entity's CPhysicsObj stand-in — owns the TargetManager // voyeur system (retail CPhysicsObj::target_manager). Replaces the @@ -607,15 +619,20 @@ public sealed class GameWindow : IDisposable // transition link (see PhysicsBody.InWorld). InWorld = true, }; - Motion = new AcDream.Core.Physics.MotionInterpreter(Body) - { - // 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(), - }; + // R5-V5: the interp is owned by the MovementManager facade + // (retail CPhysicsObj::movement_manager -> motion_interpreter). + // acdream constructs it eagerly here — retail's lazy-create + // window is never observable (see MovementManager's class doc). + Movement = new AcDream.Core.Physics.Motion.MovementManager( + new AcDream.Core.Physics.MotionInterpreter(Body) + { + // 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 // the UM router builds) — one consistent space, so the manager's // 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 mtBody = rm.Body; // 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. EntityPhysicsHost host = null!; double NowSeconds() => (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds; - rm.MoveTo = new AcDream.Core.Physics.Motion.MoveToManager( - rm.Motion, - stopCompletely: () => rmT.Motion.StopCompletely(), - getPosition: () => new AcDream.Core.Physics.Position( - rmT.CellId, mtBody.Position, mtBody.Orientation), - getHeading: () => AcDream.Core.Physics.Motion.MoveToMath.GetHeading( - mtBody.Orientation), - setHeading: (h, _) => mtBody.Orientation = - AcDream.Core.Physics.Motion.MoveToMath.SetHeading(mtBody.Orientation, h), - getOwnRadius: () => GetSetupCylinder(serverGuid, selfEntity).Radius, - getOwnHeight: () => GetSetupCylinder(serverGuid, selfEntity).Height, - contact: () => mtBody.OnWalkable, - isInterpolating: () => rmT.Interp.IsActive, - getVelocity: () => mtBody.Velocity, - getSelfId: () => serverGuid, - // R5-V2: retail CPhysicsObj::set_target/clear_target/target_quantum - // → the entity's TargetManager (replaces the AP-79 TrackedTarget* - // poll). The manager passes (0, top_level_id, 0.5, 0.0). - setTarget: (ctx, tlid, radius, q) => host.SetTarget(ctx, tlid, radius, q), - clearTarget: () => host.ClearTarget(), - getTargetQuantum: () => host.TargetManager.GetTargetQuantum(), - setTargetQuantum: q => host.TargetManager.SetTargetQuantum(q), - // R4-V5: real clock (same epoch-seconds base the per-remote tick uses). - curTime: NowSeconds); + rm.Movement.MoveToFactory = () => + { + var mtm = new AcDream.Core.Physics.Motion.MoveToManager( + rm.Motion, + stopCompletely: () => rmT.Motion.StopCompletely(), + getPosition: () => new AcDream.Core.Physics.Position( + rmT.CellId, mtBody.Position, mtBody.Orientation), + getHeading: () => AcDream.Core.Physics.Motion.MoveToMath.GetHeading( + mtBody.Orientation), + setHeading: (h, _) => mtBody.Orientation = + AcDream.Core.Physics.Motion.MoveToMath.SetHeading(mtBody.Orientation, h), + getOwnRadius: () => GetSetupCylinder(serverGuid, selfEntity).Radius, + getOwnHeight: () => GetSetupCylinder(serverGuid, selfEntity).Height, + contact: () => mtBody.OnWalkable, + isInterpolating: () => rmT.Interp.IsActive, + getVelocity: () => mtBody.Velocity, + getSelfId: () => serverGuid, + // R5-V2: retail CPhysicsObj::set_target/clear_target/target_quantum + // → the entity's TargetManager (replaces the AP-79 TrackedTarget* + // poll). The manager passes (0, top_level_id, 0.5, 0.0). + setTarget: (ctx, tlid, radius, q) => host.SetTarget(ctx, tlid, radius, q), + clearTarget: () => host.ClearTarget(), + getTargetQuantum: () => host.TargetManager.GetTargetQuantum(), + 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 // 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 = - () => rmT.MoveTo?.CancelMoveTo( + () => rmT.Movement.CancelMoveTo( AcDream.Core.Physics.WeenieError.ActionCancelled); // R5-V2: the entity's CPhysicsObj stand-in + its TargetManager. Position @@ -4350,24 +4387,22 @@ public sealed class GameWindow : IDisposable curTime: NowSeconds, physicsTimerTime: NowSeconds, getObjectA: ResolvePhysicsHost, - handleUpdateTarget: info => rmT.MoveTo?.HandleUpdateTarget(info), - interruptCurrentMovement: () => rmT.MoveTo?.CancelMoveTo( + // Retail CPhysicsObj::HandleUpdateTarget (0x00512bc0) fan head: + // 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)); rm.Host = host; _physicsHosts[serverGuid] = host; - // R5-V3 (#171, retires TS-39): bind the sticky seams to the host's - // PositionManager — - // * BeginNextNode's sticky-arrival handoff: PositionManager::StickTo - // (retail MoveToManager::BeginNextNode @0x00529d3a); - // * PerformMovement's head unstick: unstick_from_object → - // PositionManager::UnStick (MoveToManager.PerformMovement:414); - // * 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; + // R5-V5: retail MakeMoveToManager (0x00524000) — constructs the + // MoveToManager via the factory above (host now exists for the + // sticky binds inside it). The UM funnel head's unstick + // (CPhysicsObj::unstick_from_object 0x0050eaea, invoked at the mt-0 + // routing sites) binds on the interp beside it. + rm.Movement.MakeMoveToManager(); rm.Motion.UnstickFromObject = host.PositionManager.UnStick; return rm.Sink; } @@ -4506,11 +4541,12 @@ public sealed class GameWindow : IDisposable // independently (r3-port-plan §4): the manager's (each pending // animation fires MotionDone(success:false) → the bound interp pops // 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)) aeGone.Sequencer?.Manager.HandleExitWorld(); if (_remoteDeadReckon.TryGetValue(serverGuid, out var rmGone)) - rmGone.Motion.HandleExitWorld(); + rmGone.Movement.HandleExitWorld(); // R5-V2: retail CPhysicsObj::exit_world tells every voyeur of this // entity that it left the world (TargetManager::NotifyVoyeurOfEvent // ExitWorld) — a watcher moving-to/stuck-to this entity drops the @@ -4565,13 +4601,16 @@ public sealed class GameWindow : IDisposable /// builds the MovementStruct (mt 6/8 resolve the target guid /// against the entity table; unresolvable degrades to /// MoveToPosition(wire origin) / TurnToHeading(wire heading) per §2f — - /// NOT an error), and calls PerformMovement. 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). + /// NOT an error), and calls the R5-V5 facade's PerformMovement + /// (MovementManager::PerformMovement 0x005240d0 — types 6-9 + /// 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). /// private bool RouteServerMoveTo( - AcDream.Core.Physics.Motion.MoveToManager mgr, - AcDream.Core.Physics.MotionInterpreter interp, + AcDream.Core.Physics.Motion.MovementManager movement, uint cellId, AcDream.Core.Net.WorldSession.EntityMotionUpdate update) { @@ -4580,7 +4619,7 @@ public sealed class GameWindow : IDisposable { // my_run_rate write (unpack_movement @300603). if (update.MotionState.MoveToRunRate is { } mtRunRate) - interp.MyRunRate = mtRunRate; + movement.Minterp.MyRunRate = mtRunRate; var destWorld = AcDream.Core.Physics.Motion.MoveToMath .OriginToWorld( @@ -4629,7 +4668,7 @@ public sealed class GameWindow : IDisposable cellId, destWorld, System.Numerics.Quaternion.Identity); } - mgr.PerformMovement(ms); + movement.PerformMovement(ms); return true; } @@ -4669,7 +4708,7 @@ public sealed class GameWindow : IDisposable mp.DesiredHeading = wireHeading; } } - mgr.PerformMovement(ms); + movement.PerformMovement(ms); return true; } @@ -4677,8 +4716,9 @@ public sealed class GameWindow : IDisposable } /// - /// R4-V5 / R5-V2: the per-tick - /// drive (UseTime() — steering, arrival, fail-distance). The P4 + /// R4-V5 / R5-V2: the per-tick + /// drive (retail MovementManager::UseTime 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 /// through the /// voyeur system — the target's own HandleTargetting pushes updates @@ -4690,7 +4730,7 @@ public sealed class GameWindow : IDisposable /// private void TickRemoteMoveTo(RemoteMotion rm) { - rm.MoveTo?.UseTime(); + rm.Movement.UseTime(); } /// @@ -4975,14 +5015,14 @@ public sealed class GameWindow : IDisposable _playerController.Motion.DoMotion(wireStylePlayer, new AcDream.Core.Physics.Motion.MovementParameters()); - if (_playerController.MoveTo is { } playerMoveTo - && RouteServerMoveTo(playerMoveTo, _playerController.Motion, + if (_playerController.MoveTo is not null + && RouteServerMoveTo(_playerController.Movement, _playerController.CellId, update)) { if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled) { 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; } @@ -5067,8 +5107,10 @@ public sealed class GameWindow : IDisposable // R4-V5: the type-6..9 routing body is shared with the // local player (RouteServerMoveTo) — behavior identical // to the R4-V4 inline blocks it was extracted from. - if (remoteMot.MoveTo is { } moveMgr - && RouteServerMoveTo(moveMgr, remoteMot.Motion, + // (MoveTo null = EnsureRemoteMotionBindings early-outed + // on a sequencer-less entity — same skip as pre-V5.) + if (remoteMot.MoveTo is not null + && RouteServerMoveTo(remoteMot.Movement, remoteMot.CellId, update)) { return; @@ -5657,21 +5699,21 @@ public sealed class GameWindow : IDisposable rmState.Body.Position = worldPos; // #161: retail landing = MovementManager::HitGround - // (minterp → moveto, 0x00524300) with the Gravity state - // bit STILL SET (CMotionInterp::HitGround gates on - // state&0x400). The re-apply dispatches the PRESERVED - // pre-fall forward command → landing link → cycle. This - // replaces the forced SetCycle, which read the - // then-clobbered ForwardCommand (Falling) and re-set the - // pose it meant to clear. See the twin block in - // TickAnimations (VU.land). + // (minterp → moveto, 0x00524300 — the R5-V5 facade + // relay) with the Gravity state bit STILL SET + // (CMotionInterp::HitGround gates on state&0x400). The + // re-apply dispatches the PRESERVED pre-fall forward + // command → landing link → cycle. This replaces the + // forced SetCycle, which read the then-clobbered + // ForwardCommand (Falling) and re-set the pose it meant + // to clear. See the twin block in TickAnimations + // (VU.land). if (_animatedEntities.TryGetValue(entity.Id, out var aeForLand) && aeForLand.Sequencer is not null) { EnsureRemoteMotionBindings(rmState, aeForLand, update.Guid); } - rmState.Motion.HitGround(); - rmState.MoveTo?.HitGround(); + rmState.Movement.HitGround(); // DR bookkeeping only (partner of the jump-start // `State |= Gravity`). rmState.Body.State &= ~AcDream.Core.Physics.PhysicsStateFlags.Gravity; @@ -10449,15 +10491,15 @@ public sealed class GameWindow : IDisposable // then-clobbered InterpretedState.ForwardCommand // — 0x40000015 — and re-set the very Falling // cycle it meant to clear.) - rm.Motion.HitGround(); // R4-V5 (closes the V4 wiring-contract gap the // adversarial review caught): retail order — // minterp first, then moveto (MovementManager:: - // HitGround 0x00524300, §2d). Re-arms a moveto - // suspended by the airborne UseTime contact gate; - // without it a chasing NPC that lands stalls - // until ACE's ~1 Hz re-emit. - rm.MoveTo?.HitGround(); + // HitGround 0x00524300, §2d — the R5-V5 facade + // relay). Re-arms a moveto suspended by the + // airborne UseTime contact gate; without it a + // chasing NPC that lands stalls until ACE's + // ~1 Hz re-emit. + rm.Movement.HitGround(); // DR bookkeeping only (partner of the jump-start // `State |= Gravity`): stops the per-tick gravity // integration for the grounded body. @@ -12750,7 +12792,7 @@ public sealed class GameWindow : IDisposable 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)) return; @@ -12829,7 +12871,10 @@ public sealed class GameWindow : IDisposable // set it autonomous) and clobbers this moveto's dispatched motions // with the idle raw state. _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) @@ -13399,27 +13444,57 @@ public sealed class GameWindow : IDisposable // R5-V2: forward-declared so the player MoveToManager's target seams // route into the player's TargetManager (retail CPhysicsObj::set_target). EntityPhysicsHost playerHost = null!; - var playerMoveTo = new AcDream.Core.Physics.Motion.MoveToManager( - pcMoveTo.Motion, - stopCompletely: () => pcMoveTo.Motion.StopCompletely(), - getPosition: () => new AcDream.Core.Physics.Position( - pcMoveTo.CellId, pcMoveTo.Position, pcMoveTo.BodyOrientation), - getHeading: () => AcDream.Core.Physics.Motion.MoveToMath.HeadingFromYaw(pcMoveTo.Yaw), - setHeading: (h, _) => pcMoveTo.Yaw = - AcDream.Core.Physics.Motion.MoveToMath.YawFromHeading(h), - getOwnRadius: () => GetSetupCylinder(_playerServerGuid, playerEntity).Radius, - getOwnHeight: () => GetSetupCylinder(_playerServerGuid, playerEntity).Height, - contact: () => pcMoveTo.BodyInContact, - isInterpolating: () => false, - getVelocity: () => pcMoveTo.BodyVelocity, - getSelfId: () => _playerServerGuid, - // 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); + // R5-V5: the construction is the player MovementManager's + // MoveToFactory (same facade shape as EnsureRemoteMotionBindings); + // MakeMoveToManager() below invokes it once, after playerHost exists + // for the sticky binds. + _playerController.Movement.MoveToFactory = () => + { + var playerMoveTo = new AcDream.Core.Physics.Motion.MoveToManager( + pcMoveTo.Motion, + stopCompletely: () => pcMoveTo.Motion.StopCompletely(), + getPosition: () => new AcDream.Core.Physics.Position( + pcMoveTo.CellId, pcMoveTo.Position, pcMoveTo.BodyOrientation), + getHeading: () => AcDream.Core.Physics.Motion.MoveToMath.HeadingFromYaw(pcMoveTo.Yaw), + setHeading: (h, _) => pcMoveTo.Yaw = + AcDream.Core.Physics.Motion.MoveToMath.YawFromHeading(h), + getOwnRadius: () => GetSetupCylinder(_playerServerGuid, playerEntity).Radius, + getOwnHeight: () => GetSetupCylinder(_playerServerGuid, playerEntity).Height, + contact: () => pcMoveTo.BodyInContact, + isInterpolating: () => false, + getVelocity: () => pcMoveTo.BodyVelocity, + getSelfId: () => _playerServerGuid, + // 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 // the player's WORLD position (WorldEntity.Position — what the AP-79 @@ -13441,52 +13516,41 @@ public sealed class GameWindow : IDisposable curTime: () => pcMoveTo.SimTimeSeconds, physicsTimerTime: () => pcMoveTo.SimTimeSeconds, getObjectA: ResolvePhysicsHost, - handleUpdateTarget: info => _playerController?.MoveTo?.HandleUpdateTarget(info), - interruptCurrentMovement: () => _playerController?.MoveTo?.CancelMoveTo( + // Retail CPhysicsObj::HandleUpdateTarget (0x00512bc0) fan head: + // 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)); _playerHost = playerHost; _physicsHosts[_playerServerGuid] = playerHost; - // 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(); - }; - _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; + // R5-V5: retail MakeMoveToManager (0x00524000) — constructs the + // player MoveToManager via the factory above (playerHost now exists + // for the sticky binds inside it). The UM-funnel-head unstick + // (CPhysicsObj::unstick_from_object 0x0050eaea) binds on the interp + // beside it, and the controller takes the PositionManager handoff it + // drives at the retail UpdatePositionInternal/UpdateObjectInternal + // points (AdjustOffset/UseTime). + var playerMovement = _playerController.Movement; + playerMovement.MakeMoveToManager(); _playerController.Motion.UnstickFromObject = playerHost.PositionManager.UnStick; _playerController.PositionManager = playerHost.PositionManager; // TS-36 RETIRED: the interp's interrupt seam is retail's // interrupt_current_movement → MovementManager::CancelMoveTo(0x36) - // chain (raw 278189-278200). Every DoMotion/StopMotion/ - // StopCompletely/jump/set_hold_run cancel site now genuinely - // cancels a running moveto (V2's reentrancy tests prove the chain - // is inert-safe). + // chain (raw 278189-278200) — since R5-V5 the literal facade relay + // (0x005241b0). Every DoMotion/StopMotion/StopCompletely/jump/ + // set_hold_run cancel site now genuinely cancels a running moveto + // (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 = () => { if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled - && playerMoveTo.IsMovingTo()) + && playerMovement.IsMovingTo()) 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 diff --git a/src/AcDream.Core/Physics/Motion/MovementManager.cs b/src/AcDream.Core/Physics/Motion/MovementManager.cs new file mode 100644 index 00000000..7666dbd0 --- /dev/null +++ b/src/AcDream.Core/Physics/Motion/MovementManager.cs @@ -0,0 +1,171 @@ +using System; + +namespace AcDream.Core.Physics.Motion; + +/// +/// R5-V5 — retail MovementManager (acclient.h /* 3463 */, 0x10 +/// bytes / four pointers): the ONE per-entity owner of the movement pipeline's +/// two managers. Decomp extract: +/// docs/research/2026-07-03-r5-managers/r5-movementmanager-decomp.md. +/// +/// +/// 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 +/// }; +/// +/// +/// Construction mapping. Retail lazily creates BOTH children +/// (CMotionInterp::Create + enter_default_state at every entry +/// point; MoveToManager::Create via ). +/// acdream constructs the interp eagerly at entity construction +/// (RemoteMotion / PlayerMovementController ctors — the lazy +/// window is never observable; register TS-38 already covers the +/// Initted side of this) and hands it to this ctor. The moveto side +/// keeps retail's lazy mechanism: retail +/// constructs from the physics_obj/weenie_obj backpointers; +/// acdream's closure is the stand-in for those +/// two fields, carrying the full seam wiring (set once at the bind site — +/// EnsureRemoteMotionBindings / EnterPlayerModeNow / the chase +/// harness — which then calls immediately, +/// preserving the pre-facade eager timing; the ctor is side-effect-free so +/// the timing is unobservable either way). +/// +/// Deliberately NOT absorbed here (the R5-V5 slice keeps these +/// at their current owners): unpack_movement 0x00524440 ≡ the +/// GameWindow UM path + RouteServerMoveTo (Core.Net wire types stay +/// out of Core.Physics); move_to_interpreted_state 0x00524170 ≡ the +/// funnel's MotionInterpreter.MoveToInterpretedState call sites; +/// MotionDone 0x005242d0 ≡ the sequencer's MotionDoneTarget +/// seam (register AD-36); LeaveGround 0x00524320's moveto half is a +/// COMDAT no-op (see PlayerMovementController's landing comment) so +/// call sites keep invoking Minterp.LeaveGround() directly; +/// EnterDefaultState/HandleEnterWorld/ReportExhaustion/ +/// SetWeenieObject/Destroy have no acdream caller yet — +/// get_minterp 0x005242a0 ≡ the property. +/// +/// PerformMovement's set_active(1) head +/// (0x005240d9) is not re-asserted here: acdream bodies assert the Active +/// transient bit at spawn (RemoteMotion ctor) and the pre-facade +/// route never re-asserted it — status quo preserved (zero-behavior-change +/// slice), not a new deviation. +/// +public sealed class MovementManager +{ + /// Retail motion_interpreter (+0x0). Always present in + /// acdream (eager construction — see the class doc); direct child access + /// for interp-specific ops mirrors retail's get_minterp + /// (0x005242a0) callers. + public MotionInterpreter Minterp { get; } + + /// Retail moveto_manager (+0x4) — null until + /// (or a type-6..9 + /// ) creates it. + public MoveToManager? MoveTo { get; private set; } + + /// The acdream stand-in for retail's physics_obj/ + /// weenie_obj backpointers (+0x8/+0xc): the creation recipe + /// 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 + /// CPhysicsObj. + public Func? MoveToFactory { get; set; } + + public MovementManager(MotionInterpreter minterp) + { + Minterp = minterp ?? throw new ArgumentNullException(nameof(minterp)); + } + + /// + /// Retail MovementManager::MakeMoveToManager (0x00524000): + /// lazy-construct 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. + /// + public void MakeMoveToManager() + { + if (MoveTo is null && MoveToFactory is not null) + MoveTo = MoveToFactory(); + } + + /// + /// Retail MovementManager::PerformMovement (0x005240d0): the + /// type-1..9 two-way dispatch. (type - 1) > 8 (type 0 + /// underflows unsigned) → 0x47; types 1-5 → CMotionInterp:: + /// PerformMovement (return propagated); types 6-9 → + /// + MoveToManager::PerformMovement + /// whose return is NOT propagated (@0052414f return 0) — the + /// facade reports for that path. + /// + 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 + } + } + + /// Retail MovementManager::UseTime (0x005242f0): relay + /// to ONLY (does not touch the interp, does not + /// lazy-create); no-op while null. + public void UseTime() => MoveTo?.UseTime(); + + /// Retail MovementManager::HitGround (0x00524300): fan + /// to BOTH children if present — CMotionInterp::HitGround FIRST + /// (the falling-pose exit re-apply), then MoveToManager::HitGround + /// (re-arms a moveto suspended by the airborne UseTime contact + /// gate). + public void HitGround() + { + Minterp.HitGround(); + MoveTo?.HitGround(); + } + + /// Retail MovementManager::HandleExitWorld (0x00524350): + /// interp ONLY (drains pending_motions); does not touch + /// . + public void HandleExitWorld() => Minterp.HandleExitWorld(); + + /// Retail MovementManager::CancelMoveTo (0x005241b0): + /// relay to ; no-op while null. The retail + /// interrupt_current_movement → MovementManager::CancelMoveTo(0x36) + /// chain lands here. + public void CancelMoveTo(WeenieError error) => MoveTo?.CancelMoveTo(error); + + /// Retail MovementManager::HandleUpdateTarget + /// (0x00524790): relay to ; no-op while null. The + /// CPhysicsObj::HandleUpdateTarget fan (0x00512bc0) calls this + /// leg first, then the PositionManager's (host order — + /// EntityPhysicsHost.HandleUpdateTarget). + public void HandleUpdateTarget(TargetInfo info) => MoveTo?.HandleUpdateTarget(info); + + /// Retail MovementManager::IsMovingTo (0x00524260): + /// true iff exists AND reports an armed + /// move. + public bool IsMovingTo() => MoveTo?.IsMovingTo() ?? false; +} diff --git a/tests/AcDream.Core.Tests/Physics/Motion/MovementManagerTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/MovementManagerTests.cs new file mode 100644 index 00000000..da8e627d --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/Motion/MovementManagerTests.cs @@ -0,0 +1,341 @@ +using System.Numerics; +using AcDream.Core.Physics; +using AcDream.Core.Physics.Motion; +using Xunit; + +namespace AcDream.Core.Tests.Physics.Motion; + +/// +/// R5-V5 — facade conformance (retail struct +/// acclient.h /* 3463 */; methods 0x00524000-0x00524790, decomp extract +/// docs/research/2026-07-03-r5-managers/r5-movementmanager-decomp.md). +/// 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). +/// +public sealed class MovementManagerTests +{ + /// 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. + 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; + } + + /// Facade over the shared MoveToManagerHarness: the harness's + /// REAL MotionInterpreter is the minterp child; the harness's REAL + /// seam-scripted MoveToManager arrives via + /// (the acdream stand-in for retail's physics_obj/weenie_obj + /// backpointers that MakeMoveToManager constructs from). + 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); + } +} diff --git a/tests/AcDream.Core.Tests/Physics/Motion/RemoteChaseEndToEndHarnessTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/RemoteChaseEndToEndHarnessTests.cs index d184fed1..1c113938 100644 --- a/tests/AcDream.Core.Tests/Physics/Motion/RemoteChaseEndToEndHarnessTests.cs +++ b/tests/AcDream.Core.Tests/Physics/Motion/RemoteChaseEndToEndHarnessTests.cs @@ -99,7 +99,16 @@ internal sealed class RemoteChaseHarness public readonly MotionInterpreter Interp; public readonly AnimationSequencer Seq; public readonly MotionTableDispatchSink Sink; - public readonly MoveToManager Mgr; + + /// R5-V5: GameWindow's RemoteMotion.Movement twin — the ONE + /// per-entity MovementManager facade owning Interp + the MoveToManager + /// (retail CPhysicsObj::movement_manager). + public readonly MovementManager Movement; + + /// The moveto child view (RemoteMotion.MoveTo twin) — non-null + /// after the ctor's MakeMoveToManager, kept so test bodies read + /// unchanged. + public MoveToManager Mgr => Movement.MoveTo!; /// R5-V3 (#171): the creature's PositionManager facade — the /// EntityPhysicsHost-owned twin (GameWindow binds MoveToManager.StickTo/ @@ -169,44 +178,60 @@ internal sealed class RemoteChaseHarness Interp.InitializeMotionTables = () => Seq.Manager.InitializeState(); Interp.CheckForCompletedMotions = Seq.Manager.CheckForCompletedMotions; - Mgr = new MoveToManager( - Interp, - stopCompletely: () => Interp.StopCompletely(), - getPosition: () => new CorePosition(1u, Body.Position, Body.Orientation), - 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) => + // ── R5-V5: the MovementManager facade owns Interp + the moveto — + // GameWindow's RemoteMotion ctor + EnsureRemoteMotionBindings + // factory shape verbatim (sticky binds inside the factory; + // MakeMoveToManager after the host/Pm exist). ── + Movement = new MovementManager(Interp) + { + MoveToFactory = () => { - _targetArmed = tlid == PlayerGuid; - // TargetManager delivers the FIRST info synchronously on - // SetTarget (live log: HandleUpdateTarget printed directly - // after the arm, same network phase). - DeliverTargetInfo(); + var mtm = new MoveToManager( + Interp, + stopCompletely: () => Interp.StopCompletely(), + getPosition: () => new CorePosition(1u, Body.Position, Body.Orientation), + 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 = - () => Mgr.CancelMoveTo(WeenieError.ActionCancelled); + () => Movement.CancelMoveTo(WeenieError.ActionCancelled); // ── R5-V3 (#171): the PositionManager/sticky wiring — GameWindow's - // V3 additions verbatim: host-owned facade + the three seam binds - // (BeginNextNode arrival StickTo, PerformMovement-head Unstick, - // UM-funnel-head unstick_from_object). ── + // V3 additions verbatim: host-owned facade + the UM-funnel-head + // unstick_from_object bind; then MakeMoveToManager (0x00524000) + // invokes the factory above, mirroring the production bind order. ── _playerHost = new TargetHost(this); _creatureHost = new CreatureHost(this); Pm = new PositionManager(_creatureHost); - Mgr.StickTo = (tlid, radius, height) => Pm.StickTo(tlid, radius, height); - Mgr.Unstick = Pm.UnStick; + Movement.MakeMoveToManager(); Interp.UnstickFromObject = Pm.UnStick; // ── The anim-loop MotionDone binding (GameWindow.cs:10266) ── @@ -295,7 +320,9 @@ internal sealed class RemoteChaseHarness Radius = targetRadius, Height = targetHeight, }; - Mgr.PerformMovement(ms); + // R5-V5: RouteServerMoveTo twin — through the facade + // (MovementManager::PerformMovement 0x005240d0). + Movement.PerformMovement(ms); } private void DeliverTargetInfo() @@ -311,9 +338,10 @@ internal sealed class RemoteChaseHarness InterpolatedPosition = pos, }; // R5-V3 fan order (EntityPhysicsHost.HandleUpdateTarget — retail - // CPhysicsObj::HandleUpdateTarget 0x00512bc0): MoveToManager first, - // then PositionManager (the sticky consumer). - Mgr.HandleUpdateTarget(info); + // CPhysicsObj::HandleUpdateTarget 0x00512bc0): the MovementManager + // relay (@0x00512bf0 → moveto) first, then PositionManager (the + // sticky consumer). + Movement.HandleUpdateTarget(info); Pm.HandleUpdateTarget(info); } @@ -338,12 +366,12 @@ internal sealed class RemoteChaseHarness public void HandleUpdateTarget(TargetInfo info) { - _h.Mgr.HandleUpdateTarget(info); + _h.Movement.HandleUpdateTarget(info); _h.Pm.HandleUpdateTarget(info); } public void InterruptCurrentMovement() - => _h.Mgr.CancelMoveTo(WeenieError.ActionCancelled); + => _h.Movement.CancelMoveTo(WeenieError.ActionCancelled); public void SetTarget(uint contextId, uint objectId, float radius, double quantum) { @@ -399,8 +427,9 @@ internal sealed class RemoteChaseHarness if (_targetArmed && Now - _lastDeliveryTime >= _quantum) DeliverTargetInfo(); - // 2. MoveToManager drive (TickRemoteMoveTo, GameWindow.cs:9994). - Mgr.UseTime(); + // 2. MovementManager drive (TickRemoteMoveTo — the R5-V5 facade + // relay, MovementManager::UseTime 0x005242f0). + Movement.UseTime(); // 3. get_state_velocity → body velocity (the d2ccc80e refresh, // GameWindow.cs:10024).