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

@ -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;
}
}