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:
parent
6feaab91e3
commit
dccd700991
6 changed files with 833 additions and 200 deletions
|
|
@ -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<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-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 |
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
/// <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>
|
||||
/// R4-V5: the local player's verbatim retail <c>MoveToManager</c>
|
||||
/// (decomp 0x00529010-0x0052a987), constructed + seam-bound by
|
||||
/// <c>GameWindow.EnterPlayerModeNow</c> against this controller's
|
||||
/// <see cref="Motion"/>/body/Yaw (the same wiring shape
|
||||
/// <c>EnsureRemoteMotionBindings</c> uses for remotes). GameWindow
|
||||
/// routes inbound mt 6-9 movement events to
|
||||
/// <see cref="AcDream.Core.Physics.Motion.MoveToManager.PerformMovement"/>;
|
||||
/// <see cref="Update"/> ticks <c>UseTime()</c> at the slot the deleted
|
||||
/// <c>DriveServerAutoWalk</c> occupied and relays <c>HitGround()</c>
|
||||
/// (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
|
||||
/// <see cref="AcDream.Core.Physics.Motion.MovementManager.PerformMovement"/>.
|
||||
/// User input cancels a moveto through the retail chain: key edge →
|
||||
/// DoMotion (ctor-default params, CancelMoveTo bit set) →
|
||||
/// <see cref="MotionInterpreter.InterruptCurrentMovement"/> →
|
||||
/// <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>
|
||||
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>
|
||||
/// 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;
|
||||
_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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
/// <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
|
||||
/// once by EnsureRemoteMotionBindings; also bound as
|
||||
/// 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
|
||||
/// (server-directed movement), constructed + seam-bound by
|
||||
/// EnsureRemoteMotionBindings beside the R2-Q5/R3 binds.</summary>
|
||||
public AcDream.Core.Physics.Motion.MoveToManager? MoveTo;
|
||||
/// EnsureRemoteMotionBindings beside the R2-Q5/R3 binds. R5-V5:
|
||||
/// 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
|
||||
// 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 <c>MovementStruct</c> (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 <c>PerformMovement</c>. 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 <c>PerformMovement</c>
|
||||
/// (<c>MovementManager::PerformMovement</c> 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).
|
||||
/// </summary>
|
||||
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
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// R4-V5 / R5-V2: the per-tick <see cref="AcDream.Core.Physics.Motion.MoveToManager"/>
|
||||
/// drive (<c>UseTime()</c> — steering, arrival, fail-distance). The P4
|
||||
/// R4-V5 / R5-V2: the per-tick <see cref="AcDream.Core.Physics.Motion.MovementManager"/>
|
||||
/// drive (retail <c>MovementManager::UseTime</c> 0x005242f0 — the moveto
|
||||
/// side's steering, arrival, fail-distance; R5-V5 facade relay). The P4
|
||||
/// TargetTracker POLL is gone (R5-V2): target-position delivery now flows
|
||||
/// through the <see cref="AcDream.Core.Physics.Motion.TargetManager"/>
|
||||
/// voyeur system — the target's own <c>HandleTargetting</c> pushes updates
|
||||
|
|
@ -4690,7 +4730,7 @@ public sealed class GameWindow : IDisposable
|
|||
/// </summary>
|
||||
private void TickRemoteMoveTo(RemoteMotion rm)
|
||||
{
|
||||
rm.MoveTo?.UseTime();
|
||||
rm.Movement.UseTime();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
171
src/AcDream.Core/Physics/Motion/MovementManager.cs
Normal file
171
src/AcDream.Core/Physics/Motion/MovementManager.cs
Normal 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) > 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;
|
||||
}
|
||||
341
tests/AcDream.Core.Tests/Physics/Motion/MovementManagerTests.cs
Normal file
341
tests/AcDream.Core.Tests/Physics/Motion/MovementManagerTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -99,7 +99,16 @@ internal sealed class RemoteChaseHarness
|
|||
public readonly MotionInterpreter Interp;
|
||||
public readonly AnimationSequencer Seq;
|
||||
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
|
||||
/// 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).
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue