feat(B.6 slice 2): local-player auto-walk on inbound MoveToObject

Retail-faithful per MovementManager::PerformMovement (0x00524440 case 6,
decomp 300628-300648): when ACE broadcasts MoveToObject for the local
player, the local client runs its OWN auto-walk on its OWN body —
heading correction toward the target, run-forward velocity, arrival
detected via the wire's min_distance / distance_to_object predicates.

Implementation:

  PlayerMovementController:
    + IsServerAutoWalking property (read-only)
    + BeginServerAutoWalk(destWorld, minDist, objDist, moveTowards)
    + EndServerAutoWalk(reason)  // idempotent, logs to [autowalk-end]
                                  // when ACDREAM_PROBE_AUTOWALK is on
    + ApplyAutoWalkOverlay(dt, input) — called at the top of Update.
        - User movement key (Forward/Backward/Strafe/Turn) cancels.
        - Arrival predicate matches RemoteMoveToDriver / retail.
        - Heading steered toward destination at ±20° snap-on-aligned
          tolerance / π/2 rad/s rotation rate (same constants the
          remote-creature path uses).
        - Synthesizes input as Forward+Run; the rest of Update's
          MotionInterpreter + body-velocity pipeline runs unchanged.

  GameWindow.OnLiveMotionUpdated (local-player branch):
    + when update.MotionState.IsServerControlledMoveTo and MoveToPath
      is populated: translate origin to world via RemoteMoveToDriver
      .OriginToWorld, call _playerController.BeginServerAutoWalk.
    + when a non-MoveTo motion arrives and auto-walk is active:
      EndServerAutoWalk(reason="motion-non-moveto").
    + [autowalk-begin] trace line under ACDREAM_PROBE_AUTOWALK.

The mtRun=0 case from the spec trace is handled implicitly: this
slice doesn't read MoveToRunRate at all — it relies on the existing
input.Run path which uses the player's local InqRunRate (env-var
defaulted to 200). Future slice can layer in mtRun!=0 honor if needed.

Slices 3 (animation cycle source while auto-walking) and 4 (local
pickup animation echo for #64) deferred to follow-up commits.
This commit is contained in:
Erik 2026-05-14 18:50:59 +02:00
parent d82b0648b5
commit b936ef8b0b
2 changed files with 222 additions and 0 deletions

View file

@ -218,6 +218,31 @@ public sealed class PlayerMovementController
private Vector3 _prevPhysicsPos;
private Vector3 _currPhysicsPos;
// ── B.6 slice 2 (2026-05-14): local-player server-initiated auto-walk ──
// When ACE sends a MoveToObject motion for the local player (out-of-range
// Use / PickUp triggers ACE's server-side CreateMoveToChain), the wire
// payload includes a destination, arrival predicates, and a run rate.
// Retail's MovementManager::PerformMovement (0x00524440 case 6) runs a
// LOCAL auto-walk in response: heading correction toward the target,
// run-forward velocity at the wire's runRate, arrival detection via
// MoveToManager::HandleMoveToPosition. Here we keep the active auto-walk
// state and inject it into Update() as a synthesized Forward+Run input
// so the existing motion-interpreter / body-velocity pipeline runs
// unchanged. Spec: docs/superpowers/specs/2026-05-14-phase-b6-design.md.
private bool _autoWalkActive;
private Vector3 _autoWalkDestination;
private float _autoWalkMinDistance;
private float _autoWalkDistanceToObject;
private bool _autoWalkMoveTowards;
/// <summary>
/// True while a server-initiated auto-walk (MoveToObject inbound) is
/// active on the local player. The next <see cref="Update"/> call
/// synthesizes Forward+Run input and steers <see cref="Yaw"/> toward
/// the destination until arrival or user-input cancellation.
/// </summary>
public bool IsServerAutoWalking => _autoWalkActive;
public PlayerMovementController(PhysicsEngine physics)
{
_physics = physics;
@ -288,6 +313,154 @@ public sealed class PlayerMovementController
_motion.apply_current_movement(cancelMoveTo: false, allowJump: false);
}
/// <summary>
/// B.6 slice 2 (2026-05-14). Install a server-initiated auto-walk
/// against this body. <see cref="Update"/> will synthesize
/// <c>Forward+Run</c> input and steer <see cref="Yaw"/> toward
/// <paramref name="destinationWorld"/> until the body reaches the
/// arrival predicate (<c>moveTowards: dist ≤ distanceToObject</c>;
/// <c>!moveTowards: dist ≥ minDistance</c>) or the user presses any
/// movement key (which auto-cancels).
///
/// <para>
/// Retail reference: <c>MovementManager::PerformMovement</c>
/// (<c>0x00524440</c>) case 6 — unpacks the wire's target +
/// origin + run rate and calls <c>CPhysicsObj::MoveToObject</c> on
/// the local body. We do the equivalent at acdream's altitude:
/// hold the destination + thresholds + run rate locally, let the
/// existing per-tick motion machinery do the walking, and arrive
/// when the horizontal distance hits the threshold.
/// </para>
///
/// <para>
/// The run-rate parameter is the EFFECTIVE rate after the
/// <c>mtRun=0</c> fallback chain — the caller (GameWindow) is
/// responsible for substituting a non-zero rate when ACE sends 0.0
/// on the wire, per the trace finding in the design spec.
/// </para>
/// </summary>
public void BeginServerAutoWalk(
Vector3 destinationWorld,
float minDistance,
float distanceToObject,
bool moveTowards)
{
_autoWalkActive = true;
_autoWalkDestination = destinationWorld;
_autoWalkMinDistance = minDistance;
_autoWalkDistanceToObject = distanceToObject;
_autoWalkMoveTowards = moveTowards;
}
/// <summary>
/// B.6 slice 2 (2026-05-14). Cancel any active server-initiated
/// auto-walk. Idempotent. <paramref name="reason"/> is logged when
/// <see cref="PhysicsDiagnostics.ProbeAutoWalkEnabled"/> is on so
/// the trace shows why the auto-walk ended.
/// </summary>
public void EndServerAutoWalk(string reason)
{
if (!_autoWalkActive) return;
_autoWalkActive = false;
if (PhysicsDiagnostics.ProbeAutoWalkEnabled)
Console.WriteLine($"[autowalk-end] reason={reason}");
}
/// <summary>
/// B.6 slice 2 (2026-05-14). If a server-initiated auto-walk is
/// active, either cancel it (user pressed a movement key) or
/// synthesize a Forward+Run input with <see cref="Yaw"/> stepped
/// toward the destination. Returns the (possibly modified) input
/// for the rest of <see cref="Update"/> to consume.
///
/// <para>
/// Heading correction matches <see cref="RemoteMoveToDriver.Drive"/>
/// — ±<see cref="RemoteMoveToDriver.HeadingSnapToleranceRad"/>
/// snap-on-aligned, otherwise rotate at
/// <see cref="RemoteMoveToDriver.TurnRateRadPerSec"/>. Arrival
/// predicate matches retail's
/// <c>MoveToManager::HandleMoveToPosition</c>: chase arrives at
/// <c>distanceToObject</c>; flee arrives at <c>minDistance</c>.
/// </para>
/// </summary>
private MovementInput ApplyAutoWalkOverlay(float dt, MovementInput input)
{
if (!_autoWalkActive) return input;
// User-input cancellation. Any direct movement key takes over.
// Mouse-only turning (no movement key) doesn't cancel — the
// user might just be looking around mid-walk.
bool userOverride = input.Forward || input.Backward
|| input.StrafeLeft || input.StrafeRight
|| input.TurnLeft || input.TurnRight;
if (userOverride)
{
EndServerAutoWalk("user-input");
return input;
}
// Horizontal distance to target — server owns Z, our local body
// Z snaps to UpdatePosition broadcasts when ACE sends them.
var pos = _body.Position;
float dx = _autoWalkDestination.X - pos.X;
float dy = _autoWalkDestination.Y - pos.Y;
float dist = MathF.Sqrt(dx * dx + dy * dy);
// Arrival predicate (RemoteMoveToDriver convention; matches retail).
float arrivalThreshold = _autoWalkMoveTowards
? _autoWalkDistanceToObject
: _autoWalkMinDistance;
bool arrived =
(_autoWalkMoveTowards
&& dist <= arrivalThreshold + RemoteMoveToDriver.ArrivalEpsilon)
|| (!_autoWalkMoveTowards
&& dist >= arrivalThreshold - RemoteMoveToDriver.ArrivalEpsilon);
if (arrived)
{
EndServerAutoWalk("arrived");
return input; // falls through to Ready (no Forward) → body stops
}
// Step Yaw toward target. Convention from Update line 364:
// _body.Orientation = Quaternion.CreateFromAxisAngle(Z, Yaw - π/2),
// so local-forward (+Y) maps to world (cos Yaw, sin Yaw, 0).
// Therefore Yaw that faces (dx,dy) is atan2(dy, dx).
if (dist > 1e-4f)
{
float desiredYaw = MathF.Atan2(dy, dx);
float delta = desiredYaw - Yaw;
while (delta > MathF.PI) delta -= 2f * MathF.PI;
while (delta < -MathF.PI) delta += 2f * MathF.PI;
if (MathF.Abs(delta) <= RemoteMoveToDriver.HeadingSnapToleranceRad)
{
Yaw = desiredYaw;
}
else
{
float maxStep = RemoteMoveToDriver.TurnRateRadPerSec * dt;
Yaw += MathF.Sign(delta) * MathF.Min(MathF.Abs(delta), maxStep);
}
while (Yaw > MathF.PI) Yaw -= 2f * MathF.PI;
while (Yaw < -MathF.PI) Yaw += 2f * MathF.PI;
}
// Synthesize "running forward" input. The rest of Update reads
// Yaw + input.Forward + input.Run to drive _motion + body
// velocity exactly as it does for user-driven W+Shift. We zero
// any mouse delta so a stale frame doesn't fight the steering.
return input with
{
Forward = true,
Run = true,
Backward = false,
StrafeLeft = false,
StrafeRight = false,
TurnLeft = false,
TurnRight = false,
MouseDeltaX = 0f,
};
}
// L.2a slice 1 (2026-05-12): centralized CellId mutation so the
// [cell-transit] probe fires from a single chokepoint. Both the
// server-snap path (SetPosition) and the per-frame resolver path
@ -329,6 +502,15 @@ public sealed class PlayerMovementController
public MovementResult Update(float dt, MovementInput input)
{
// B.6 slice 2 (2026-05-14): server-initiated auto-walk overlay.
// When _autoWalkActive, steer Yaw toward _autoWalkDestination and
// synthesize Forward+Run input so the rest of Update runs the
// body forward as if the user were holding W. User movement-key
// input cancels the auto-walk (retail UX). Arrival check fires
// before synthesizing, so a one-frame arrival doesn't waste a
// tick of velocity past the target.
input = ApplyAutoWalkOverlay(dt, input);
// Portal-space guard: while teleporting, no input is processed and
// no physics is resolved. Return a zero-movement result so the caller
// can detect the frozen state (MotionStateChanged = false, no commands).

View file

@ -3313,6 +3313,46 @@ public sealed class GameWindow : IDisposable
Console.WriteLine(System.FormattableString.Invariant(
$"[autowalk-mt] stance=0x{stance:X4} cmd={cmdHex} mt=0x{update.MotionState.MovementType:X2} isMoveTo={update.MotionState.IsServerControlledMoveTo} moveTowards={update.MotionState.MoveTowards} {pathStr} {spd} {mtsSpd} {mtsRun}"));
}
// B.6 slice 2 (2026-05-14): drive the local player's body
// through a server-initiated auto-walk when ACE sends
// MoveToObject (movement type 6) — retail-faithful per
// MovementManager::PerformMovement 0x00524440 case 6. When
// the inbound motion is NOT a MoveTo, cancel any active
// auto-walk (server intent changed).
if (_playerController is not null)
{
if (update.MotionState.IsServerControlledMoveTo
&& update.MotionState.MoveToPath is { } pathData)
{
// Translate landblock-local origin → world space.
var destWorld = AcDream.Core.Physics.RemoteMoveToDriver
.OriginToWorld(
pathData.OriginCellId,
pathData.OriginX,
pathData.OriginY,
pathData.OriginZ,
_liveCenterX,
_liveCenterY);
_playerController.BeginServerAutoWalk(
destWorld,
pathData.MinDistance,
pathData.DistanceToObject,
update.MotionState.MoveTowards);
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
{
Console.WriteLine(System.FormattableString.Invariant(
$"[autowalk-begin] dest=({destWorld.X:F2},{destWorld.Y:F2},{destWorld.Z:F2}) minDist={pathData.MinDistance:F2} objDist={pathData.DistanceToObject:F2} towards={update.MotionState.MoveTowards}"));
}
}
else if (_playerController.IsServerAutoWalking)
{
// A non-MoveTo motion arrived (e.g., Ready, or a
// user-input echo) — server's auto-walk intent is
// gone; release the local driver.
_playerController.EndServerAutoWalk("motion-non-moveto");
}
}
}
else
{