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

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