fix(B.6): turn-first auto-walk + tiny margin; close #67 doors; file #68 remote arrival

10 Hz heartbeat (301281d) made ACE see us in-radius before its
MoveToChain timeout; user confirmed doors work now. Closing #67 — root
cause was 1 Hz position outbound on our side, not anything door-
specific. Same fix unblocked door + NPC.

Two visible refinements:

1. Turn-first gate. User report: 'when I use from far range, I should
   face that object and then start moving. Now it starts running
   before facing is complete.'
   ApplyAutoWalkOverlay now suppresses Forward motion when the
   heading delta to the target is > 30°. Body turns IN PLACE first,
   then walks forward once roughly aligned. Within the 30° band the
   body walks while finishing the residual turn. Matches the user-
   observed retail rhythm.

2. Arrival margin shrunk 0.2 m → 0.05 m. User report: 'NPC dialogue
   fires, but still a bit too close. In retail it fires from a longer
   range.' With the 10 Hz heartbeat the server-side Player.Location
   tracks us within ~100 ms, so the bigger safety margin is no longer
   needed — only a tiny epsilon to absorb the sub-tick race between
   local arrival fire and the next outbound packet.

Filed #68: remote players' running animation doesn't transition to
Ready on auto-walk arrival when observed from acdream. Separate
visual bug — server-side action completes correctly; just the cycle
on the dead-reckoned remote body doesn't flip back to idle.
This commit is contained in:
Erik 2026-05-15 11:49:55 +02:00
parent 301281d8d0
commit 32352af583
2 changed files with 62 additions and 37 deletions

View file

@ -46,35 +46,44 @@ Copy this block when adding a new issue:
# Active issues
## #67 — Door Use action doesn't complete after auto-walk arrival
## #68 — Remote players don't stop running animation on auto-walk arrival
**Status:** OPEN
**Severity:** MEDIUM (M1-affecting — doors are M1 demo target 2)
**Severity:** LOW-MEDIUM (visual only — server-side action completes correctly)
**Filed:** 2026-05-15 (B.7 visual verification)
**Component:** net / interaction
**Component:** motion / remote dead-reckoning / animation cycle
**Description:** After B.6's auto-walk + B.7's flush-AP-and-re-send, NPC
dialogue fires correctly on out-of-range Use. But Use on a door from
out-of-range still doesn't open the door — the player walks to the
door, sends the re-send, and nothing happens.
**Description:** Observing a retail player from acdream as they approach
an NPC at a distance: the remote body's run animation keeps cycling
even after the body has visibly stopped at the NPC. Retail-side the
character stopped; the action (dialogue) fired; but our client's
animation never transitioned RunForward → Ready.
**Suspected causes (need investigation):**
1. Door's `objDist` on the wire might be different from what we
parse, so the local arrival lands outside ACE's actual
door-use radius.
2. ACE's `Door.ActOnUse` may require additional client-side state
(specific stance, motion, facing) that our re-sent Use lacks.
3. The door's ObjectDescriptionFlags `BF_DOOR (0x1000)` may not
be properly captured / forwarded for our picker's per-type
radius logic to fire.
**Suspected:** `RemoteMoveToDriver` detects arrival via
`DriveResult.Arrived`, but the consumer site (per-tick loop in
`GameWindow.TickAnimations` or wherever the remote body's cycle is
driven) doesn't flip the animation cycle back to Ready on arrival.
Alternatively the cycle persists because ACE doesn't broadcast a
follow-up `UpdateMotion(Ready)` — relying on the client to detect
arrival from the wire's distance threshold instead.
**Acceptance:** F or R-key on a selected door from > 0.6 m runs the
auto-walk, arrives, opens the door (player walks through; B.4c swing
animation plays).
**Files (likely):**
- `src/AcDream.App/Rendering/GameWindow.cs` — wherever per-tick motion
for remote entities reads `RemoteMoveToDriver`'s state. Need to
call `SetCycle(NonCombat, Ready)` on arrival.
**Files (likely):** `src/AcDream.App/Rendering/GameWindow.cs`
`SendUse`; `src/AcDream.App/Input/PlayerMovementController.cs`
arrival; `references/ACE/Source/ACE.Server/WorldObjects/Door.cs`.
**Acceptance:** Retail player observed running up to an NPC visibly
stops running animation at arrival distance, transitions to idle.
---
## #67 — [DONE 2026-05-15 · `301281d`] Door Use action doesn't complete after auto-walk arrival
**Status:** DONE — fixed by `301281d` (10 Hz heartbeat during motion).
With ACE seeing our position in near-real-time, its `CreateMoveToChain`
converges normally for doors as well as NPCs. Root cause was 1 Hz
position sync on our side, not anything door-specific. User confirmed
doors work after the heartbeat bump.
---

View file

@ -445,22 +445,17 @@ public sealed class PlayerMovementController
float dy = _autoWalkDestination.Y - pos.Y;
float dist = MathF.Sqrt(dx * dx + dy * dy);
// Arrival predicate. CRITICAL: ACE's server-side WithinUseRadius
// is strict (dist <= radius), so arriving exactly at the radius
// boundary fails — ACE rejects the action and replies with
// another MoveToObject. We walk slightly INSIDE the boundary so
// the re-sent action lands safely in-range.
//
// The margin is small — user feedback says retail fires Use
// from longer range, so we minimise the over-walk: 0.2 m at
// typical NPC radii (3 m → arrive at 2.8 m), tapered for tight
// pickup radii (0.6 m → arrive at 0.48 m) so the body stays
// reachable but always inside ACE's strict check.
// Arrival predicate. With the 10 Hz heartbeat from 301281d the
// server-side Player.Location tracks our body within ~100 ms, so
// the previous "subtract 0.2 m safety margin" workaround is no
// 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.
float arrivalThreshold = _autoWalkMoveTowards
? _autoWalkDistanceToObject
: _autoWalkMinDistance;
float safetyMargin = MathF.Min(0.2f, arrivalThreshold * 0.2f);
float effectiveArrival = MathF.Max(arrivalThreshold - safetyMargin, 0.1f);
const float TinyMargin = 0.05f;
float effectiveArrival = MathF.Max(arrivalThreshold - TinyMargin, 0.1f);
bool arrived =
(_autoWalkMoveTowards
&& dist <= effectiveArrival)
@ -476,6 +471,14 @@ public sealed class PlayerMovementController
// _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).
//
// User feedback (2026-05-15): 'I should face that object and then
// start moving. Now it starts running before facing is complete.'
// Track the current heading delta — if we're more than the
// walk-while-turning threshold off, suppress Forward this frame
// so the body turns IN PLACE first. Once we're within the
// threshold, the synthesised Forward+Run kicks in below.
bool aligned = true;
if (dist > 1e-4f)
{
float desiredYaw = MathF.Atan2(dy, dx);
@ -493,6 +496,14 @@ public sealed class PlayerMovementController
}
while (Yaw > MathF.PI) Yaw -= 2f * MathF.PI;
while (Yaw < -MathF.PI) Yaw += 2f * MathF.PI;
// 30° "walk-while-turning" threshold: outside this, body
// turns in place. Inside, body walks forward while finishing
// any remaining alignment. Matches retail-feel observation;
// exact retail value is in MoveToManager but ~30° is a
// sensible heuristic for now.
const float WalkWhileTurningRad = 30f * MathF.PI / 180f;
aligned = MathF.Abs(delta) <= WalkWhileTurningRad;
}
// Walk vs run decided ONCE at BeginServerAutoWalk based on
@ -503,6 +514,11 @@ public sealed class PlayerMovementController
// all the way to the object and then stop").
bool shouldRun = _autoWalkInitiallyRunning;
// 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;
// Synthesize "moving 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 (+ optional Shift).
@ -510,8 +526,8 @@ public sealed class PlayerMovementController
// steering.
return input with
{
Forward = true,
Run = shouldRun,
Forward = moveForward,
Run = moveForward && shouldRun,
Backward = false,
StrafeLeft = false,
StrafeRight = false,