diff --git a/src/AcDream.App/Input/PlayerMovementController.cs b/src/AcDream.App/Input/PlayerMovementController.cs
index 29b68fe..0cf7b4b 100644
--- a/src/AcDream.App/Input/PlayerMovementController.cs
+++ b/src/AcDream.App/Input/PlayerMovementController.cs
@@ -451,21 +451,25 @@ public sealed class PlayerMovementController
// longer needed. Tiny 0.05 m margin remains to absorb the
// sub-tick race between local arrival-fire and the next
// heartbeat's outbound packet.
+ //
+ // ARRIVAL IS GATED ON ALIGNMENT: we only end the auto-walk once
+ // the body is BOTH within use-radius AND facing the target.
+ // Without the alignment gate, a Use on a close target while
+ // facing away would end immediately and the body wouldn't turn
+ // at all (user feedback 2026-05-15: 'when I'm close I'm not
+ // facing'). The alignment check is computed below in the same
+ // block as the heading-step; we defer the arrival fire-and-end
+ // until after we've inspected `aligned`.
float arrivalThreshold = _autoWalkMoveTowards
? _autoWalkDistanceToObject
: _autoWalkMinDistance;
const float TinyMargin = 0.05f;
float effectiveArrival = MathF.Max(arrivalThreshold - TinyMargin, 0.1f);
- bool arrived =
+ bool withinArrival =
(_autoWalkMoveTowards
&& dist <= effectiveArrival)
|| (!_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),
@@ -506,6 +510,16 @@ public sealed class PlayerMovementController
aligned = MathF.Abs(delta) <= WalkWhileTurningRad;
}
+ // End the auto-walk once the body is BOTH within use radius
+ // AND aligned with the target. This is the alignment-gated
+ // arrival the comment above flagged: a close-range Use on a
+ // target behind the player still rotates the body first.
+ if (withinArrival && aligned)
+ {
+ EndServerAutoWalk("arrived");
+ return input;
+ }
+
// Walk vs run decided ONCE at BeginServerAutoWalk based on
// initial distance — held for the rest of the auto-walk so the
// character keeps running all the way to the target instead of
@@ -516,8 +530,9 @@ public sealed class PlayerMovementController
// Turn-first gate: if not yet aligned with the target, suppress
// forward motion so the body turns in place rather than
- // walking an arc.
- bool moveForward = aligned;
+ // walking an arc. Also suppress when already within arrival —
+ // we just turned to face it; no need to step forward into it.
+ bool moveForward = aligned && !withinArrival;
// Synthesize "moving forward" input. The rest of Update reads
// Yaw + input.Forward + input.Run to drive _motion + body
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index b54eb60..d2d960d 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -9113,6 +9113,17 @@ public sealed class GameWindow : IDisposable
_debugVm?.AddToast("Not in world");
return;
}
+ // B.6 (2026-05-15): install a speculative auto-walk on the local
+ // player toward the target. For far targets ACE will overwrite
+ // this with its own MovementType=6 wire payload (and a better
+ // wire-supplied use-radius). For close-range targets ACE skips
+ // MoveToChain entirely and just rotates server-side; our
+ // overlay provides the matching local rotation. Either way the
+ // alignment-gated arrival ensures the body finishes facing
+ // the target before stopping.
+ if (!isRetryAfterArrival)
+ InstallSpeculativeTurnToTarget(guid);
+
var seq = _liveSession.NextGameActionSequence();
var body = AcDream.Core.Net.Messages.InteractRequests.BuildUse(seq, guid);
_liveSession.SendGameAction(body);
@@ -9137,6 +9148,11 @@ public sealed class GameWindow : IDisposable
_debugVm?.AddToast("Not in world");
return;
}
+ // B.6 (2026-05-15): same speculative turn-to-target install as
+ // SendUse — close-range pickup still rotates locally to face
+ // the item before the pickup resolves server-side.
+ if (!isRetryAfterArrival)
+ InstallSpeculativeTurnToTarget(itemGuid);
// B.5 polish (2026-05-14): silently block client-side when the
// selected entity is a creature/NPC. ACE's
@@ -9197,6 +9213,62 @@ public sealed class GameWindow : IDisposable
else SendUse(guid, isRetryAfterArrival: true);
}
+ ///
+ /// B.6 (2026-05-15). Install a local auto-walk overlay against the
+ /// given target entity at Use / PickUp send time. ACE's response
+ /// branches by distance:
+ ///
+ /// - Far target → ACE broadcasts a MovementType=6
+ /// MoveToObject which arrives shortly after and overwrites
+ /// our speculative state with ACE's wire-supplied use-radius
+ /// and origin. No conflict; same target either way.
+ /// - Close target → ACE skips MoveToChain (WithinUseRadius
+ /// shortcut at Player_Move.cs:66) and rotates the body
+ /// server-side via Rotate(target). ACE doesn't broadcast
+ /// anything actionable to us, so our pre-installed overlay
+ /// handles the local rotation — body turns to face the target
+ /// in place, then ends.
+ ///
+ ///
+ /// Per-type use radius mirrors the picker's radius heuristic:
+ /// 3 m for creatures, 2 m for doors / lifestones / portals,
+ /// 0.6 m for everything else (ground items).
+ ///
+ ///
+ private void InstallSpeculativeTurnToTarget(uint targetGuid)
+ {
+ if (_playerController is null) return;
+ if (!_entitiesByServerGuid.TryGetValue(targetGuid, out var entity))
+ return;
+
+ // Per-type use radius — same heuristic as the picker's
+ // radiusForGuid callback.
+ float useRadius = 0.6f;
+ if (_liveEntityInfoByGuid.TryGetValue(targetGuid, out var info)
+ && (info.ItemType & AcDream.Core.Items.ItemType.Creature) != 0)
+ {
+ useRadius = 3.0f;
+ }
+ else if (_lastSpawnByGuid.TryGetValue(targetGuid, out var spawn)
+ && spawn.ObjectDescriptionFlags is { } odf)
+ {
+ // BF_DOOR | BF_LIFESTONE | BF_PORTAL | BF_CORPSE
+ const uint LargeFlatMask = 0x1000u | 0x4000u | 0x40000u | 0x2000u;
+ if ((odf & LargeFlatMask) != 0) useRadius = 2.0f;
+ }
+
+ _playerController.BeginServerAutoWalk(
+ destinationWorld: entity.Position,
+ minDistance: 0f,
+ distanceToObject: useRadius,
+ moveTowards: true,
+ // High walkRunThreshold → walk mode by default. If the
+ // body is far enough to actually need to run, ACE's
+ // subsequent MovementType=6 will overwrite with the
+ // wire's real threshold.
+ walkRunThreshold: 9999f);
+ }
+
///
/// B.6+B.7 (2026-05-15). Send an out-of-frame AutonomousPosition
/// packet using the controller's current authoritative state.