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

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

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

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

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

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

View file

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