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

View file

@ -411,7 +411,18 @@ public sealed class GameWindow : IDisposable
internal sealed class RemoteMotion // internal: the R2-Q5 sink callbacks (ObservedOmega seam) capture it
{
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

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