acdream/docs/superpowers/specs/2026-05-14-phase-b6-design.md
Erik d82b0648b5 docs(B.6): record Slice 1 trace findings — ACE sends mtRun=0.00, no UP echo
Captured a live ACDREAM_PROBE_AUTOWALK trace double-clicking +Je from
~3.5m. Findings folded into the spec's State at design freeze section:

1. Wire parser is correct (matches ACE MoveToObject.Write +
   MoveToParameters.Write byte-for-byte).
2. ACE sends mtRun=0.00. Not a parser bug — that's the wire value.
   Retail's apply_run_to_command (0x00527BE0) fell back to the
   player's own rate; our Slice 2 needs the same fallback chain.
3. Player position never changed during the entire trace — current
   behavior is pure no-op on the inbound MoveToObject (literally
   ignored, as our code at OnLiveMotionUpdated:3289 suggests).
4. ACE does NOT broadcast UpdatePosition for the local player during
   auto-walk. Definitively kills Option C — nothing to blend with.
   Local body must drive itself.

The trace validates the spec's Option A path. Slice 2 implementation
can proceed without further wire-format guessing.
2026-05-14 18:45:17 +02:00

422 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Phase B.6 — Local-player auto-walk for server-initiated MoveToObject — design
**Date:** 2026-05-14.
**Status:** DESIGN — implementation deferred to a dedicated session.
**Closes:** [issue #63](../../ISSUES.md) (server-initiated `MoveToObject` auto-walk not honored).
**Predecessors:** [B.5](../plans/2026-05-14-phase-b5-pickup.md) (ground-item pickup, close-range path) + [B.4b](../plans/2026-05-13-phase-b4b-plan.md) (outbound Use chain).
**References:**
- ACE server-side: [`Player_Move.cs:37179`](../../../references/ACE/Source/ACE.Server/WorldObjects/Player_Move.cs) (`CreateMoveToChain`, `MoveToChain`, `MoveTo`).
- ACE pickup driver: [`Player_Inventory.cs:9761106`](../../../references/ACE/Source/ACE.Server/WorldObjects/Player_Inventory.cs).
- Reference port (Rust): [holtburger `simulation.rs:3341` + `178191`](../../../references/holtburger/crates/holtburger-core/src/client/simulation.rs).
- Existing acdream infra: [`RemoteMoveToDriver.cs`](../../../src/AcDream.Core/Physics/RemoteMoveToDriver.cs), [`ServerControlledLocomotion.cs`](../../../src/AcDream.Core/Physics/ServerControlledLocomotion.cs).
---
## Problem statement
When the local player triggers a `Use (0x0036)` or `PutItemInContainer
(0x0019)` on a target outside ACE's `WithinUseRadius` (default 0.6 m),
ACE's `CreateMoveToChain` initiates a **server-side auto-walk** toward
the target. ACE also broadcasts `Motion(MovementType=MoveToObject,
target=X)` via `EnqueueBroadcastMotion`.
Our client currently does NOT honor this server-initiated motion for
the local player. The visible symptom: the character drifts a short
distance toward the target, then "snaps back" to the original
position. ACE's `MoveToChain` polls `WithinUseRadius` every 0.1 s; if
the player never enters the radius before `defaultMoveToTimeout` fires,
the chain calls `callback(false)` which broadcasts
`InventoryServerSaveFailed (ActionCancelled)` and the pickup / use
never completes.
**User-facing impact:**
- Double-click on a ground item from any distance → walk + snap, no pickup.
- F-press on a ground item from > 0.6 m → walk + snap, no pickup.
- Use on an out-of-range NPC → same.
Close-range (≤ 0.6 m) Use and PickUp work correctly via ACE's
early-return branch at `Player_Move.cs:66` (the `WithinUseRadius`
shortcut that skips the chain entirely).
---
## Current state — wire data we already have
`UpdateMotion (0xF74D)` is already parsed end-to-end. The parser
populates `EntityMotionUpdate.MotionState` with:
| Field | Source | Notes |
|---|---|---|
| `MovementType` | wire byte | `MoveToObject = 6`, `MoveToPosition = 7`. |
| `IsServerControlledMoveTo` | derived | True when `MovementType ∈ {6, 7}`. |
| `MoveToPath` | wire (when set) | `(OriginCellId, OriginX/Y/Z, MinDistance, DistanceToObject)` — the auto-walk destination, plus arrival predicates. |
| `MoveToSpeed`, `MoveToRunRate`, `MoveToCanRun`, `MoveTowards` | wire | speed scalars + chase-vs-flee bit. |
The remote-creature path at
[`GameWindow.cs:33463425`](../../../src/AcDream.App/Rendering/GameWindow.cs)
already consumes all of this — `_remoteDeadReckon` per-entity state
captures `MoveToDestinationWorld`, `MoveToMinDistance`,
`MoveToDistanceToObject`, `MoveToMoveTowards`, and
`HasMoveToDestination`. The per-tick driver
[`RemoteMoveToDriver`](../../../src/AcDream.Core/Physics/RemoteMoveToDriver.cs)
computes heading + arrival and is exercised on every NPC chase.
**Gap:** none of this fires for `update.Guid == _playerServerGuid`. The
local-player branch is gated out at:
1. `GameWindow.cs:3289``if (update.Guid == _playerServerGuid)` skips
`SetCycle` so `UpdatePlayerAnimation` stays authoritative.
2. `GameWindow.cs:3346` — `_remoteDeadReckon.TryGetValue(update.Guid,
…)` returns false because the local player isn't in
`_remoteDeadReckon`.
The wire is parsed, the destination is in `MotionState.MoveToPath`, and
nothing in our client reads it for the local player.
---
## Why the player visibly walks-then-snaps today
A. ACE broadcasts `MotionUpdated(MoveToObject)` → our client ignores it
for the local player (above).
B. ACE's server-side `PhysicsObj.MoveToObject(…)` runs its own
simulation. ACE updates the player's authoritative `Location` on its
side every physics tick.
C. ACE periodically broadcasts the player's updated position back to
everyone — including the player themselves — via `UpdatePosition`.
The local-player's `PositionUpdated` handler in `GameWindow` likely
applies these snapshots, producing the visible forward motion.
D. After `defaultMoveToTimeout` (server-side configurable, typically
on the order of seconds), ACE gives up. `MoveToChain` calls
`callback(false)`. Server-side, ACE may issue a position correction
that returns the player to where the chain started.
E. The local prediction (our `PlayerMovementController` running on
user input — which is "no movement keys held") reconciles toward
"stationary at original spot", producing the snap-back when ACE's
own corrections stop arriving.
The exact source of the visible motion (step C vs. some other path) is
unverified — flagged as **investigation work** below.
---
## Solution candidates
### Option A — Run the existing remote-driver for the local player
**Approach.** When `OnLiveMotionUpdated` sees `IsServerControlledMoveTo`
for `_playerServerGuid`, install the same per-tick steering machinery
that drives remote creatures: heading toward
`MoveToDestinationWorld`, run/walk cycle from
`ServerControlledLocomotion.PlanMoveToStart`, arrival detected via
`min_distance` (chase) / `distance_to_object` (flee).
While the auto-walk is active, suppress user-input driven movement so
the two control paths don't fight. On arrival or on a non-MoveTo motion
arriving, release auto-walk and restore user input.
**Pros.** Retail-faithful. Reuses well-tested infrastructure. Aligns
the local player's behavior with how every remote creature already
handles `MoveToObject`.
**Cons.** Largest implementation surface. Needs careful state-machine
design to prevent input/auto-walk fighting and ensure clean release on
ESC, arrival, packet cancel, target despawn, login transitions.
### Option B — Visual settle to arrival pose
**Approach.** When MoveToObject arrives, compute the approximate
arrival position via the holtburger
`approximate_move_to_object_projection_target` formula
(`target_pos - normalize(target_pos - source) × (DistanceToObject +
UseRadius)`). Smoothly tween the local-player camera/mesh from current
position to that arrival pose over the expected walk duration. Don't
send any extra packets; rely on ACE's own physics to walk the
authoritative position in parallel.
**Pros.** Smaller scope (~50 LOC). No state machine. No reconciliation
fight — the tween is purely visual; the underlying
`PlayerMovementController` is paused for the duration of the tween.
**Cons.** Not retail-faithful. Doesn't actually run the player's
physics integrator forward, so any side effects of normal movement
(footstep emit hooks, collision resolution mid-walk, environment
triggers) don't fire. Likely fine for an item pickup (no environment to
interact with mid-walk), but is a band-aid that doesn't generalize.
### Option C — Server-position-authoritative blend
**Approach.** Detect `IsServerControlledMoveTo` for the local player.
While active, treat inbound `UpdatePosition` as authoritative without
reconciliation — i.e. trust the server's interpolated positions and
suppress the local prediction's snap-back. Don't try to drive
local-side; let ACE's broadcasts drag us along.
**Pros.** Very small surface (~20 LOC). No new state machine; just a
flag that gates reconciliation.
**Cons.** Depends on ACE actually broadcasting per-tick UpdatePosition
during the auto-walk. The visible "walks then snaps" pattern suggests
it does broadcast for a while and then stops; need to confirm. If
ACE's broadcast cadence is too sparse, the motion is choppy.
---
## Recommendation
**Option A is the retail-faithful path; Options B and C are non-retail
shortcuts. Implement Option A.**
### Retail evidence (settles A vs C)
`MovementManager::PerformMovement` at retail address `0x00524440`
(decomp `docs/research/named-retail/acclient_2013_pseudo_c.txt` lines
300628300648) is the inbound-motion dispatcher. The switch on movement
type has explicit cases for `MoveToObject (6)` and `MoveToPosition (7)`
that:
1. Call `MovementManager::MakeMoveToManager(this)` to ensure the
per-physics-object manager exists.
2. Unpack the target guid + origin position + `MovementParameters` from
the wire.
3. Set `this->motion_interpreter->my_run_rate` from the packet.
4. Call **`CPhysicsObj::MoveToObject(this->physics_obj, target_guid,
&params)`** — which kicks off a **fully local** auto-walk on the
player's own physics body (`0x00512860`, decomp line 280598).
5. Fall through to `MoveToManager::MoveToPosition` only if the target
guid isn't found in the local physics world (rare; usually means the
client hasn't streamed in the target yet).
This is the **same code path** that runs for every remote creature
chasing the player — retail did not have a separate "remote-only" vs
"local-only" auto-walk pipeline. The local client's MoveToManager
actively drove the local player's body forward when the server sent
MoveToObject. ACE's server-side `PhysicsObj.MoveToObject` simulation
runs in parallel for authoritative-position tracking + arrival
detection (`MoveToChain.WithinUseRadius`), but the visible movement on
the local client comes from the local MoveToManager — not from
inbound UpdatePosition packets.
Option C would diverge from retail by relying on server position
broadcasts instead of local physics integration. That risks combat
movement, environment-trigger interactions, and animation hooks all
diverging from retail because they'd be driven by sparse server-side
position snapshots rather than smooth local integration.
### Existing acdream infrastructure that's already retail-shaped
We have most of the building blocks already:
- [`RemoteMoveToDriver`](../../../src/AcDream.Core/Physics/RemoteMoveToDriver.cs)
is the per-tick steering loop — heading correction, arrival via
`min_distance` / `distance_to_object`, ±20° aux-turn tolerance —
ported from retail's `MoveToManager::HandleMoveToPosition`
(`0x00529d80`). It's already exercised on every NPC chase. The
retail-faithful fix for the local player **reuses this driver**,
installed against the local player's body instead of a remote's
dead-reckoned body.
- [`ServerControlledLocomotion.PlanMoveToStart`](../../../src/AcDream.Core/Physics/ServerControlledLocomotion.cs)
already does what retail's `MovementParameters::get_command`
(`0x0052AA00`) does: seed `WalkForward` / `RunForward` depending on
`CanRun` + `MoveToSpeed` + `MoveToRunRate`.
- `MotionState.MoveToPath` is fully parsed on the wire. Remote chase
reads it at `GameWindow.cs:34013417`.
The B.6 work is essentially **"do for `_playerServerGuid` what we
already do for remotes,"** with one extra concern: the local player has
a user-input motion source (`PlayerMovementController`) that has to
yield to the auto-walk while it's active.
### Why not B
Tweens aren't retail-faithful and would diverge worse than C.
Eliminated.
---
## Required investigation before any code
The "walks then snaps back" symptom is observed but not yet
characterized in detail. We need a live trace of:
1. Outbound: timestamp + opcode + payload of the user's Use / PickUp
packet that triggers the auto-walk.
2. Inbound during the auto-walk: every `UpdateMotion`, `UpdatePosition`,
`VectorUpdate`, `WeenieError`, `InventoryServerSaveFailed` for the
local player, timestamped.
3. Visible client position at each inbound event (so we can correlate
"walks forward at frame N" with the inbound that caused it).
**Implementation step:** add a runtime-gated diagnostic
`ACDREAM_PROBE_AUTOWALK=1` that logs one line per relevant event during
local-player auto-walk attempts. Roughly 30 LOC; mirrors L.2a slice 1's
`ACDREAM_PROBE_RESOLVE` / `ACDREAM_PROBE_CELL` pattern.
The trace is no longer needed to *decide between options* (retail
decomp settles that — Option A wins), but it remains valuable as a
**baseline measurement** for the Option A implementation: knowing what
ACE sends today lets us verify the local driver behaves equivalently
on the wire (no extra packets needed, position broadcasts arrive at
expected cadence, the auto-walk completes inside
`defaultMoveToTimeout` instead of timing out).
---
## File-level scope sketch (Option A — retail-faithful)
Mirror retail's `MovementManager::PerformMovement` case 6 against
acdream's existing `PlayerMovementController` + `RemoteMoveToDriver`.
**Slice 1 — diagnostic baseline (~30 LOC).**
- **Modify:** `src/AcDream.Core/Physics/PhysicsDiagnostics.cs` — add
`ProbeAutoWalkEnabled` flag gated on `ACDREAM_PROBE_AUTOWALK=1`.
- **Modify:** `GameWindow.OnLiveMotionUpdated`, `OnLivePositionUpdated`,
`OnVectorUpdated`, `SendUse`, `SendPickUp` — when probe is on and the
guid is `_playerServerGuid`, log one line per event with timestamp +
payload. Mirror the `[resolve]` / `[cell-transit]` line format from
L.2a.
**Slice 2 — install auto-walk on inbound MoveToObject (~100 LOC).**
- **Modify:** `PlayerMovementController` — add `BeginServerAutoWalk(
Vector3 destinationWorld, float minDistance, float distanceToObject,
bool moveTowards, float moveToSpeed, float moveToRunRate, bool
canRun)` + `EndServerAutoWalk(reason)` methods. The controller owns
the "input vs auto-walk" state. While auto-walking, the per-tick
update calls `RemoteMoveToDriver.Step(...)` against its own body, and
the user-input motion path is suppressed.
- **Modify:** `GameWindow.OnLiveMotionUpdated` — when `update.Guid ==
_playerServerGuid && IsServerControlledMoveTo && MoveToPath is
not null`, translate the path's `OriginCellId + OriginXYZ` to world
space (same `RemoteMoveToDriver.OriginToWorld` helper the remote
path uses), call `_playerController.BeginServerAutoWalk(...)`.
Otherwise (a non-MoveTo motion arrives for the player), call
`EndServerAutoWalk(reason="motion-changed")`.
- **Modify:** `PlayerMovementController.Tick` — if auto-walking,
consume input only for cancellation (W/A/S/D pressed → cancel
auto-walk → restore input); skip the input-driven velocity solve;
let `RemoteMoveToDriver.Step` set the body's velocity + heading;
apply arrival check via `min_distance` / `distance_to_object`; on
arrival, call `EndServerAutoWalk(reason="arrived")`.
**Slice 3 — animation cycle selection (~20 LOC).**
- **Modify:** `GameWindow` `UpdatePlayerAnimation` (the path that
drives the local player's animation cycle from user input) — when
the controller is in `IsServerAutoWalking` state, source the cycle
from `ServerControlledLocomotion.PlanMoveToStart(...)` instead of the
user-input MotionInterpreter. This is what makes the local player
visibly walk + animate during the auto-walk.
**Slice 4 — local pickup animation (probably closes #64, ~10 LOC).**
- **Modify:** `OnLiveMotionUpdated` — for `_playerServerGuid`, allow
`Motion(Pickup)` / `Motion(Pickup5/10/15/20)` to drive `SetCycle`
bypassing the existing self-echo filter at `GameWindow.cs:3289`.
This is the bend-down animation retail observers see when we pick up
an item. Same one-shot mechanism retail used; `UpdatePlayerAnimation`
doesn't predict it because it's server-initiated, so admitting the
echo is correct.
Total: estimated ~160 LOC + unit tests for the controller state
machine. No new files; all changes land in existing physics + render
infrastructure.
---
## Acceptance criteria
- [ ] Double-click a ground item from 25 m away. Character walks to
the item, picks it up, despawn fires, inventory updates.
- [ ] F-press on a selected item from 25 m away. Same behavior as
double-click.
- [ ] Use a Holtburg NPC from 25 m away. Character walks to the NPC,
chat dialogue appears (NPC interaction completes, like the
close-range case from M1 target 3).
- [ ] No regression on close-range pickup (B.5 path remains 1-tap
works).
- [ ] User can interrupt the auto-walk by pressing a movement key (W /
A / S / D). Auto-walk releases; input takes over; ACE's
`MoveToChain` reads "not arrived" + "user cancelled" and stops.
- [ ] Pressing ESC during auto-walk releases the auto-walk and returns
to normal player mode.
- [ ] No "walks then snaps back" visual artifact under any of the
scenarios above.
---
## Out of scope (deferred to later phases)
- `MoveToPosition` (movement type 7) for the local player — typically
used by portal traversal and admin-teleport flows. M4 territory.
- `Stick` / sticky-target tracking — used by combat chase. M2/M3
territory.
- Sphere-cylinder distance variant — relevant for vendor / corpse
interactions where the target has non-trivial extent. Can be added
in B.6's follow-up commit if needed.
- Local player's `MoveToObject` initiation via wire (we currently rely
on ACE to start the auto-walk on the player's behalf — that's the
retail behavior and we don't need a client-initiated path).
---
## Carry-overs from B.5
- **#64** — local-player pickup animation. Same self-echo filter at
`OnLiveMotionUpdated:3289` that ignores MoveToObject also drops the
inbound `Motion(Pickup)` retail observers render correctly. Slice 4
above admits server-initiated one-shot motions through the filter
for the local player, which should close #64. Verify in the same
visual-test pass.
---
## State at design freeze
- **Main HEAD:** `281d125` (initial B.6 design spec committed).
- **No code changes in this spec commit** — design document only.
- **Spec update 2026-05-14 (this commit):** retail decomp at
`MovementManager::PerformMovement` (`0x00524440` case 6, decomp lines
300628300648) decisively settles A vs C in favor of **Option A**.
Retail's local client ran its own `MoveToManager` and called
`CPhysicsObj::MoveToObject` on the local player's body. Option C
(server-position-blend) is not retail-faithful and is no longer
considered.
- **Slice 1 shipped 2026-05-14** (`eda8278` + `1b4f3ba`):
`ACDREAM_PROBE_AUTOWALK` diagnostic + DebugPanel checkbox.
### Trace-captured findings (post-Slice-1)
A live trace captured at Holtburg with the probe enabled —
double-clicking `+Je` (a remote player at `(111.34, 5.96, 94.01)` in
cell `0xA9B40021`) from ~3.5 m away, 4 successive Use sends. Findings:
1. **Parser confirmed correct.** Each `[autowalk-mt]` line reads
`mt=0x06 isMoveTo=True moveTowards=True
path=cell=0xA9B40021,xyz=(111.34,5.96,94.01),minDist=0.00,objDist=0.50
mtSpd=1.00 mtRun=0.00`. Matches ACE's
`MoveToObject.Write` + `MoveToParameters.Write` byte-for-byte.
2. **ACE sends `mtRun=0.00`** — not a parser bug. ACE's
`Player_Move.MoveTo` default for unspecified `runRate` is `0.0f`,
and the call into MoveToObject uses that wire value directly. Retail
decomp at `0x005245e9` copies the wire value into
`motion_interpreter->my_run_rate`; if 0, the local MoveToManager
falls back to the player's own run-rate via
`CMotionInterp::apply_run_to_command` (`0x00527BE0`).
3. **Player position never changed** during the entire trace. All
`[autowalk-up]` lines after the 4 Use sends report
`pos=(112.32, 9.36, 94.00)` verbatim — current behavior is pure
no-op on the inbound MoveToObject.
4. **ACE does NOT broadcast `UpdatePosition` for the local player
during the auto-walk.** This kills Option C even more firmly than
the retail decomp did. The local body has to drive itself.
5. **Slice 2 must handle `mtRun == 0.0`** — fall back to the player's
own current run rate. The trace also captured `fwdSpd=2.86` for
the user's normal running before the auto-walk attempt — that's
the server-echoed `ForwardSpeed`, available as a recent-history
source for the fallback. Default `1.0` if no echo yet.
### Next session entry point
Slice 2: add `PlayerMovementController.BeginServerAutoWalk` /
`EndServerAutoWalk` + per-tick steering via `RemoteMoveToDriver`-style
heading + arrival. Wire in `GameWindow.OnLiveMotionUpdated` with the
`mtRun=0` fallback chain. Suppress user-input motion while
auto-walking; cancel on movement-key press.