From b936ef8b0b88a5950ae1cd359a4ba3595e983c03 Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 14 May 2026 18:50:59 +0200 Subject: [PATCH] feat(B.6 slice 2): local-player auto-walk on inbound MoveToObject MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../Input/PlayerMovementController.cs | 182 ++++++++++++++++++ src/AcDream.App/Rendering/GameWindow.cs | 40 ++++ 2 files changed, 222 insertions(+) diff --git a/src/AcDream.App/Input/PlayerMovementController.cs b/src/AcDream.App/Input/PlayerMovementController.cs index cb9b34b..2a4896b 100644 --- a/src/AcDream.App/Input/PlayerMovementController.cs +++ b/src/AcDream.App/Input/PlayerMovementController.cs @@ -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; + + /// + /// True while a server-initiated auto-walk (MoveToObject inbound) is + /// active on the local player. The next call + /// synthesizes Forward+Run input and steers toward + /// the destination until arrival or user-input cancellation. + /// + 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); } + /// + /// B.6 slice 2 (2026-05-14). Install a server-initiated auto-walk + /// against this body. will synthesize + /// Forward+Run input and steer toward + /// until the body reaches the + /// arrival predicate (moveTowards: dist ≤ distanceToObject; + /// !moveTowards: dist ≥ minDistance) or the user presses any + /// movement key (which auto-cancels). + /// + /// + /// Retail reference: MovementManager::PerformMovement + /// (0x00524440) case 6 — unpacks the wire's target + + /// origin + run rate and calls CPhysicsObj::MoveToObject 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. + /// + /// + /// + /// The run-rate parameter is the EFFECTIVE rate after the + /// mtRun=0 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. + /// + /// + public void BeginServerAutoWalk( + Vector3 destinationWorld, + float minDistance, + float distanceToObject, + bool moveTowards) + { + _autoWalkActive = true; + _autoWalkDestination = destinationWorld; + _autoWalkMinDistance = minDistance; + _autoWalkDistanceToObject = distanceToObject; + _autoWalkMoveTowards = moveTowards; + } + + /// + /// B.6 slice 2 (2026-05-14). Cancel any active server-initiated + /// auto-walk. Idempotent. is logged when + /// is on so + /// the trace shows why the auto-walk ended. + /// + public void EndServerAutoWalk(string reason) + { + if (!_autoWalkActive) return; + _autoWalkActive = false; + if (PhysicsDiagnostics.ProbeAutoWalkEnabled) + Console.WriteLine($"[autowalk-end] reason={reason}"); + } + + /// + /// 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 stepped + /// toward the destination. Returns the (possibly modified) input + /// for the rest of to consume. + /// + /// + /// Heading correction matches + /// — ± + /// snap-on-aligned, otherwise rotate at + /// . Arrival + /// predicate matches retail's + /// MoveToManager::HandleMoveToPosition: chase arrives at + /// distanceToObject; flee arrives at minDistance. + /// + /// + 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). diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index f25f58b..76b09d7 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -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 {