feat(retail): Phase B.6 — server-driven auto-walk done right
Closes #63, #69, #74, #75. Replaces the chain of Commit-B workarounds that compensated for ACE's MoveToChain getting cancelled by a leaked user-MoveToState packet during inbound auto-walk. The fix is architectural — auto-walk drives the body directly from the server-supplied path data, no player-input synthesis, no spurious wire-packet transitions, no grace-period band-aid. Architectural change (closes #75): PlayerMovementController.ApplyAutoWalkOverlay → DriveServerAutoWalk. - Steps Yaw toward target at retail-faithful turn rates. - Computes desired forward velocity from path runRate. - Calls _motion.DoMotion(WalkForward, speed) directly for the motion-interpreter state (drives animation cycle). - Sets _body.set_local_velocity directly when grounded. - Returns true to gate the user-input motion + velocity section in Update so user-input flow doesn't overwrite auto-walk velocity or motion state. Mirrors retail's MovementManager::PerformMovement case 6 (decomp 0x00524440) which never touches the user-input pipeline during server-controlled auto-walk. Wire-layer guard at GameWindow.cs:6419 retained as a SEMANTIC statement (`if (result.MotionStateChanged && !IsServerAutoWalking)`): user-MoveToState packets are for user-driven motion intent. During server-controlled auto-walk, the motion-state transitions caused by the animation override (RunForward / WalkForward / TurnLeft / TurnRight cycles) must not leak as user-cancellation packets. This is NOT the deleted 500ms grace-period band-aid; it's the wire-layer expressing the user-vs-server motion split. Animation plumbed for auto-walk phases (closes #69): - Moving forward → WalkForward (speed=1.0) / RunForward (speed=runRate) - Turn-first phase → TurnLeft / TurnRight (sign of yawStep) - Aligned-but-pre-step / arrival → no override (idle) Driven via _autoWalkMovingForwardThisFrame + _autoWalkTurnDirectionThisFrame fields set in DriveServerAutoWalk and read in the MovementResult construction at the bottom of Update. UpdatePlayerAnimation picks up the localAnimCmd as the highest-priority animation source. Walk/run threshold = 1.0m, retail-observed. ACE's wire-default of 15.0f is too generous; ACE's own physics layer uses 1.0f at MovementParameters.cs:50 (with the 15.0f line commented out) and Creature.cs:312 notes "default 15 distance seems too far". The formula matches retail's MovementParameters::get_command at decomp 0x0052aa00: running = (initialDist - distance_to_object) >= threshold, evaluated ONCE at chain start and held for the rest of the auto-walk (matches retail "runs all the way / walks all the way" behaviour). Wire-supplied threshold is ignored. Pickup gate (IsPickupableTarget) now uses BF_STUCK (acclient.h:6435, bit 0x4) to discriminate immovable scenery from real pickup items that share a Misc ItemType. Sign (pwd=0x14 with BF_STUCK) → blocked; spell component (pwd=0x10, no BF_STUCK) → allowed. ACE's PutItemInContainer (Player_Inventory.cs:831-836) responds with WeenieError.Stuck (0x29) on stuck items so the gate prevents wasted wire packets + a UX dead-end. R-key dispatch by target type. UseCurrentSelection's top-level IsUseableTarget gate was wrong (blocked USEABLE_NO=1 items that ARE pickupable). Reordered: 1. Creature → SendUse 2. Pickupable → SendPickUp 3. Useable → SendUse 4. Otherwise → "cannot be used" toast Each handler keeps its own gate. Matches retail's per-action server-side validation. AP cadence revert (closes #74). With the MoveToChain race fixed, the per-frame "send while moving" cadence is no longer load-bearing. Reverted to retail's two-branch ShouldSendPositionEvent gate (acclient_2013_pseudo_c.txt:700233-700285): Interval NOT elapsed (< 1 sec): send if cell or contact-plane changed. Interval elapsed (>= 1 sec): send if cell or position frame changed. Adds _lastSentContactPlane field + ApproxPlaneEqual helper + PlayerMovementController.ContactPlane public accessor. Extended NotePositionSent(Vector3, uint, Plane, float) — both outbound sites (MoveToState + AP) pass _playerController.ContactPlane. Effective rates: 0 Hz idle, ~1 Hz smooth motion, per-event on cell/plane changes, 0 Hz airborne. CLAUDE.md updated with no-workarounds rule (commit `da126f9` on the worktree branch). Saved as feedback memory at memory/feedback_no_workarounds.md. Tests: build green; Core.Net 294/294; Core 1073/1081 (baseline, 8 pre-existing Physics failures unchanged). Visual-verified end-to-end on 2026-05-16 for far/near Use + PickUp on NPCs, doors, items, spell components, signs (correctly blocked), corpses, turn-first animation, run/walk thresholds, idle quiet, smooth- motion 1Hz. Spec: docs/superpowers/specs/2026-05-16-phase-b6-suppress-movetostate-during-inbound-autowalk-design.md Plan: docs/superpowers/plans/2026-05-16-phase-b6-suppress-movetostate-during-inbound-autowalk.md Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b5da17db76
commit
d640ed74e1
6 changed files with 1317 additions and 200 deletions
20
CLAUDE.md
20
CLAUDE.md
|
|
@ -179,6 +179,26 @@ The only thing that genuinely requires stopping is **visual confirmation** — t
|
||||||
user needs to look at the running client and tell you whether it matches
|
user needs to look at the running client and tell you whether it matches
|
||||||
retail. Everything else is your call.
|
retail. Everything else is your call.
|
||||||
|
|
||||||
|
**No workarounds without explicit approval.** When you spot a bug or
|
||||||
|
encounter a behavioral mismatch, fix the underlying cause — do not ship a
|
||||||
|
band-aid, suppression flag, grace period, retry loop, or any other "make
|
||||||
|
the symptom go away" shortcut, unless the user has explicitly approved
|
||||||
|
that shape OR you are building a NEW feature with a different design.
|
||||||
|
This rule exists because every workaround creates architectural debt that
|
||||||
|
masks the real issue, makes future refactors harder, and erodes the
|
||||||
|
codebase's retail-faithfulness. Examples of disallowed shortcuts: an
|
||||||
|
`if (problematicState) return early` guard at the symptom site instead of
|
||||||
|
investigating why the state happened; a timer-based "settle period" to
|
||||||
|
hide a race; a flag like `_suppressXDuringY` to mask a wire-level mistake;
|
||||||
|
a `try/catch` swallowing an exception that signals a real problem. If you
|
||||||
|
notice a fix is starting to look like a workaround mid-implementation,
|
||||||
|
stop, file the proper investigation as an issue with full reproduction
|
||||||
|
notes, and either (a) ask the user before shipping the workaround, or
|
||||||
|
(b) invest the time to fix the root cause. The user has explicitly
|
||||||
|
authorized "spend more time, get it right" over "ship a shortcut and
|
||||||
|
file the cleanup." Quote them: "we should have no workarounds unless I
|
||||||
|
say so or we want a different feature."
|
||||||
|
|
||||||
**Only stop and wait for the user when:**
|
**Only stop and wait for the user when:**
|
||||||
|
|
||||||
- Visual verification is the acceptance test ("does the drudge look right now?")
|
- Visual verification is the acceptance test ("does the drudge look right now?")
|
||||||
|
|
|
||||||
|
|
@ -46,13 +46,68 @@ Copy this block when adding a new issue:
|
||||||
|
|
||||||
# Active issues
|
# Active issues
|
||||||
|
|
||||||
## #74 — AP cadence is per-frame-while-moving, more chatty than retail
|
## #75 — [DONE 2026-05-16 · `f035ea3`] Auto-walk should drive body directly, not synthesize player-input
|
||||||
|
|
||||||
**Status:** OPEN
|
**Status:** DONE
|
||||||
|
**Severity:** LOW (functionally correct via grace-period band-aid; architectural cleanup only)
|
||||||
|
**Filed:** 2026-05-16
|
||||||
|
**Component:** physics / auto-walk
|
||||||
|
|
||||||
|
**Resolution (2026-05-16 · `f035ea3`):** Refactored `ApplyAutoWalkOverlay` → `DriveServerAutoWalk`. Auto-walk now steps Yaw, sets `_body.set_local_velocity` from runRate, and calls `_motion.DoMotion(WalkForward, speed)` directly — NO `MovementInput` synthesis. `Update` gates the user-input motion + velocity section on `!autoWalkConsumedMotion` to prevent overwrite. The 500ms arrival grace period (band-aid) deleted. The wire-layer `!IsServerAutoWalking` guard at `GameWindow.cs:6419` retained as a semantic statement (user-MoveToState is for user-driven intent only), not as a band-aid for the synthesis leak that no longer exists. Animation cycle plumbed through via `localAnimCmd` / `localAnimSpeed` for both moving-forward and turn-first phases (issue #69 folded in). Walk/run threshold corrected to 1.0m (overrides ACE's wire-supplied 15.0f; matches user-observed retail behaviour + ACE's own physics layer default). `IsPickupableTarget` now checks `BF_STUCK` (`acclient.h:6435`) to correctly block signs/banners that share Misc ItemType with real pickup items.
|
||||||
|
|
||||||
|
**Description:** `ApplyAutoWalkOverlay` in `PlayerMovementController`
|
||||||
|
synthesizes `Forward+Run` `MovementInput` during inbound `MoveToObject`
|
||||||
|
so the existing motion-interpreter pipeline drives the body. The
|
||||||
|
synthesis leaks: motion-interpreter sets `MotionStateChanged=true`,
|
||||||
|
which would fire an outbound `MoveToState` "user is running"
|
||||||
|
packet to ACE — interpreted as user-took-manual-control and cancels
|
||||||
|
ACE's `MoveToChain`. We mitigate with a guard
|
||||||
|
(`!_playerController.IsServerAutoWalking` at `GameWindow.cs:6410`)
|
||||||
|
plus a 500 ms post-arrival grace period to cover ACE's poll race.
|
||||||
|
|
||||||
|
Retail's `MoveToManager::HandleMoveToPosition` (decomp 0x0052xxxx)
|
||||||
|
steps the body POSITION directly when server `MoveToObject` arrives —
|
||||||
|
NO player-input synthesis, NO motion-interpreter involvement, NO
|
||||||
|
outbound MoveToState. Holtburger
|
||||||
|
([simulation.rs:178-206](references/holtburger/crates/holtburger-core/src/client/simulation.rs))
|
||||||
|
follows the same pattern (sets `ServerControlledProjection`, advances
|
||||||
|
the body, returns empty).
|
||||||
|
|
||||||
|
**Acceptance:** Refactor auto-walk to step `_body.Position` (or
|
||||||
|
equivalent) directly from the wire-supplied path data + run rate, NOT
|
||||||
|
via synthesized input. Motion state during auto-walk becomes a
|
||||||
|
SERVER-DRIVEN state (similar to how remote players' motion is driven
|
||||||
|
by inbound MoveToState packets), not a USER-DRIVEN one. The 500 ms
|
||||||
|
grace period in `EndServerAutoWalk` becomes unnecessary and can be
|
||||||
|
deleted; same for the `IsServerAutoWalking` guard at the wire layer
|
||||||
|
(no MoveToState would have been built in the first place).
|
||||||
|
|
||||||
|
Animation cycle currently driven by motion-interpreter's
|
||||||
|
`MotionStateChanged → SetCycle(RunForward)` would need a separate
|
||||||
|
path: probably mirror how remote-player animation is driven by
|
||||||
|
inbound motion packets (the sequencer accepts a `SetCycle` directly).
|
||||||
|
|
||||||
|
**Files:** `src/AcDream.App/Input/PlayerMovementController.cs`
|
||||||
|
(`ApplyAutoWalkOverlay` returns synthesized input today; refactor to
|
||||||
|
step body directly + drive animation via `_animationSequencer.SetCycle`
|
||||||
|
directly). `src/AcDream.App/Rendering/GameWindow.cs` (delete the
|
||||||
|
`!IsServerAutoWalking` guard once the leak is gone).
|
||||||
|
|
||||||
|
**Estimated scope:** Medium (~50-100 LOC + careful testing of
|
||||||
|
animation cycle continuity). Not blocking M1 — the grace-period
|
||||||
|
band-aid produces retail-faithful behaviour empirically.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## #74 — [DONE 2026-05-16 · `de44358`] AP cadence is per-frame-while-moving, more chatty than retail
|
||||||
|
|
||||||
|
**Status:** DONE
|
||||||
**Severity:** LOW (works; just sends ~60× the packets retail would during smooth motion)
|
**Severity:** LOW (works; just sends ~60× the packets retail would during smooth motion)
|
||||||
**Filed:** 2026-05-16
|
**Filed:** 2026-05-16
|
||||||
**Component:** physics / net cadence
|
**Component:** physics / net cadence
|
||||||
|
|
||||||
|
**Resolution (2026-05-16 · `de44358`):** With #75 (MoveToState suppression refactor) closing the MoveToChain-cancellation race, the per-frame "send while moving" cadence is no longer load-bearing. Reverted to retail's two-branch `ShouldSendPositionEvent` gate (`acclient_2013_pseudo_c.txt:700233-700285`): cell/plane change during the sub-interval; cell-or-frame change after the 1s heartbeat. Added `_lastSentContactPlane` field + extended `NotePositionSent(Vector3, uint, Plane, float)` + added `ApproxPlaneEqual` helper + `PlayerMovementController.ContactPlane` public accessor. Effective rates now match retail: 0 Hz idle, ~1 Hz smooth motion, per-event on cell/plane changes, 0 Hz airborne.
|
||||||
|
|
||||||
**Description:** The diff-driven AP cadence shipped in Commit B fires
|
**Description:** The diff-driven AP cadence shipped in Commit B fires
|
||||||
`HeartbeatDue` on **any** position change each frame while grounded
|
`HeartbeatDue` on **any** position change each frame while grounded
|
||||||
on walkable (effective ~60 Hz during smooth movement) and a 1 Hz
|
on walkable (effective ~60 Hz during smooth movement) and a 1 Hz
|
||||||
|
|
@ -214,13 +269,15 @@ Not blocking M1.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## #69 — Local player rotation isn't animated (no leg/arm cycle while pivoting)
|
## #69 — [DONE 2026-05-16 · `f035ea3`] Local player rotation isn't animated (no leg/arm cycle while pivoting)
|
||||||
|
|
||||||
**Status:** OPEN
|
**Status:** DONE
|
||||||
**Severity:** LOW (visual polish — rotation works, just looks stiff)
|
**Severity:** LOW (visual polish — rotation works, just looks stiff)
|
||||||
**Filed:** 2026-05-15 (B.6 close-range turn-to-face)
|
**Filed:** 2026-05-15 (B.6 close-range turn-to-face)
|
||||||
**Component:** motion / animation cycle
|
**Component:** motion / animation cycle
|
||||||
|
|
||||||
|
**Resolution (2026-05-16 · `f035ea3`):** Fixed as part of the auto-walk architectural refactor (issue #75). `DriveServerAutoWalk` now records the per-frame rotation direction in `_autoWalkTurnDirectionThisFrame` (+1 / -1 / 0); the animation override at the bottom of `Update` reads that flag and sets `localAnimCmd` to `TurnLeft` / `TurnRight` during the turn-first phase. User confirmed 2026-05-16 that the auto-walk turn-first case (click target, body rotates before walking) now plays the leg-shuffle animation. User-driven A/D rotation was always working — the original issue description was specific to the auto-walk turn-first case.
|
||||||
|
|
||||||
**Description:** When the auto-walk overlay rotates the local player
|
**Description:** When the auto-walk overlay rotates the local player
|
||||||
(close-range Use turn-to-face, or turn-first phase of a far-range walk),
|
(close-range Use turn-to-face, or turn-first phase of a far-range walk),
|
||||||
the body's Yaw rotates smoothly but no leg / arm animation plays —
|
the body's Yaw rotates smoothly but no leg / arm animation plays —
|
||||||
|
|
@ -422,14 +479,20 @@ locally on send (mirroring retail's client behavior).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## #63 — Server-initiated auto-walk (MoveToObject) not honored
|
## #63 — [DONE 2026-05-16 · `f035ea3`] Server-initiated auto-walk (MoveToObject) not honored
|
||||||
|
|
||||||
**Status:** OPEN
|
**Status:** DONE
|
||||||
**Severity:** MEDIUM (blocks out-of-range Use + Pickup; close-range
|
**Severity:** MEDIUM (blocks out-of-range Use + Pickup; close-range
|
||||||
works fine)
|
works fine)
|
||||||
**Filed:** 2026-05-14 (B.5 visual verification)
|
**Filed:** 2026-05-14 (B.5 visual verification)
|
||||||
**Component:** motion / inbound MoveToObject handling
|
**Component:** motion / inbound MoveToObject handling
|
||||||
|
|
||||||
|
**Resolution (2026-05-16):** Closed in two parts:
|
||||||
|
1. **B.6 slice 2 (2026-05-14):** inbound MoveToObject parsing + `BeginServerAutoWalk` wiring at `GameWindow.cs:3389` — body auto-walks toward the server-supplied destination.
|
||||||
|
2. **B.6 #75 refactor (`f035ea3`, 2026-05-16):** `ApplyAutoWalkOverlay → DriveServerAutoWalk` drives the body directly from path data, no input synthesis. The `MoveToState` leak that previously cancelled ACE's `MoveToChain` callback is gone; the chain runs uninterrupted and `TryUseItem` / `TryPickUp` fires server-side on arrival. No client-side retry needed. Walk/run threshold corrected to 1.0m (matches retail-observed; overrides ACE's wire-default 15m).
|
||||||
|
|
||||||
|
Visual-verified end-to-end: far-range Use on NPCs / doors / spell components / corpses all complete via ACE's server-side callback. The far-range retry workaround from Task 1's first iteration (`c61d049`'s `_pendingPostArrivalAction` arming) was deleted as part of #75 (`f035ea3`).
|
||||||
|
|
||||||
**Description:** When the player triggers a Use or PutItemInContainer
|
**Description:** When the player triggers a Use or PutItemInContainer
|
||||||
on a target outside ACE's `WithinUseRadius` (default 0.6 m), ACE
|
on a target outside ACE's `WithinUseRadius` (default 0.6 m), ACE
|
||||||
runs server-side auto-walk via `CreateMoveToChain` →
|
runs server-side auto-walk via `CreateMoveToChain` →
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,631 @@
|
||||||
|
# Phase B.6 — Suppress MoveToState during inbound auto-walk Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Stop sending outbound `MoveToState` while ACE's server-initiated auto-walk is driving the player, then retire the Commit B workarounds that compensated for the resulting `MoveToChain` cancellation. ACE's `TryUseItem` callback fires on arrival; client sends Use exactly once.
|
||||||
|
|
||||||
|
**Architecture:** One-line guard against the misleading wire packet, two retry-assignment deletions, one revert of the AP-cadence gate to retail's narrow shape. No new files, no new tests (behavioral change is wire-level integration; covered by existing Core.Net suite + user visual verify).
|
||||||
|
|
||||||
|
**Tech Stack:** C# .NET 10. Edits touch `AcDream.App/Rendering/GameWindow.cs`, `AcDream.App/Input/PlayerMovementController.cs`, `docs/ISSUES.md`.
|
||||||
|
|
||||||
|
**Spec:** [docs/superpowers/specs/2026-05-16-phase-b6-suppress-movetostate-during-inbound-autowalk-design.md](../specs/2026-05-16-phase-b6-suppress-movetostate-during-inbound-autowalk-design.md).
|
||||||
|
|
||||||
|
**Retail anchors:**
|
||||||
|
- `Player_Use.cs:205` (ACE) — `CreateMoveToChain(item, (success) => TryUseItem(item, success))`.
|
||||||
|
- `Player_Move.cs:150` (ACE) — chain polls and fires `callback(true)` when within use radius.
|
||||||
|
- `acclient_2013_pseudo_c.txt:700233-700285` — retail `ShouldSendPositionEvent`: narrow gate (cell-or-plane change during sub-interval; frame change after interval; gated on `Contact && OnWalkable`).
|
||||||
|
- `acclient_2013_pseudo_c.txt:700327` — retail `SendPositionEvent`: `(state & 1) != 0 && (state & 2) != 0`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File structure
|
||||||
|
|
||||||
|
| File | Responsibility | Touched in tasks |
|
||||||
|
|---|---|---|
|
||||||
|
| `src/AcDream.App/Rendering/GameWindow.cs` | Outbound wire layer. Guard MoveToState build (Task 1); delete two retry assignments + log strings (Task 1); update NotePositionSent call sites to pass contact plane (Task 3). | 1, 3 |
|
||||||
|
| `src/AcDream.App/Input/PlayerMovementController.cs` | Diff-driven cadence state + the auto-walk overlay. Add `_lastSentContactPlane` field; extend `NotePositionSent` signature; replace per-frame `positionChanged` gate with retail's narrow gate (Task 3). | 3 |
|
||||||
|
| `docs/ISSUES.md` | Close `#63` and `#74` to "Recently closed" (Task 5). | 5 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Suppress outbound MoveToState during server auto-walk + delete the retry workarounds
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/AcDream.App/Rendering/GameWindow.cs` line ~6410 (the MoveToState send block).
|
||||||
|
- Modify: `src/AcDream.App/Rendering/GameWindow.cs` line ~9203 (SendUse far-range path).
|
||||||
|
- Modify: `src/AcDream.App/Rendering/GameWindow.cs` line ~9265 (SendPickUp far-range path).
|
||||||
|
|
||||||
|
### Step 1: Guard the outbound MoveToState build
|
||||||
|
|
||||||
|
Find the block at `GameWindow.cs:6410` that reads:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
if (result.MotionStateChanged)
|
||||||
|
{
|
||||||
|
// HoldKey axis values — retail enum (holtburger types.rs HoldKey):
|
||||||
|
```
|
||||||
|
|
||||||
|
Change the condition to:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// 2026-05-16 (Phase B.6): suppress outbound MoveToState while
|
||||||
|
// ACE's server-initiated auto-walk is driving the player.
|
||||||
|
// Synthesized Forward+Run input in ApplyAutoWalkOverlay leaks
|
||||||
|
// to MotionStateChanged=true; sending the resulting "user is
|
||||||
|
// RunForward" wire packet makes ACE cancel its own MoveToChain
|
||||||
|
// (Player_Move.cs:150 callback never fires). Retail and
|
||||||
|
// holtburger walk the body locally during inbound MoveToObject
|
||||||
|
// WITHOUT sending an outbound MoveToState — AutonomousPosition
|
||||||
|
// alone is enough for ACE's WithinUseRadius poll.
|
||||||
|
if (result.MotionStateChanged && !_playerController.IsServerAutoWalking)
|
||||||
|
{
|
||||||
|
// HoldKey axis values — retail enum (holtburger types.rs HoldKey):
|
||||||
|
```
|
||||||
|
|
||||||
|
(Only the `if` line changes; the comment above is new. Leave the rest of the block untouched.)
|
||||||
|
|
||||||
|
- [ ] **Step 2: Delete the SendUse far-range retry assignment**
|
||||||
|
|
||||||
|
Find the SendUse method's far-range block. Search:
|
||||||
|
|
||||||
|
```
|
||||||
|
grep -n "Far range:" src/AcDream.App/Rendering/GameWindow.cs
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: line ~9197. The block reads (paraphrased — find the exact text in the file):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Far range: send Use immediately so ACE has the request,
|
||||||
|
// AND queue an arrival re-send. Issue #63 (server-initiated
|
||||||
|
// MoveToObject not honored) means ACE's first-Use response
|
||||||
|
// is dropped as too-far and ACE doesn't re-poll
|
||||||
|
// WithinUseRadius when the speculative local walk gets us in
|
||||||
|
// range. The arrival re-send fires a second Use packet once
|
||||||
|
// the body reaches the target — at which point ACE accepts
|
||||||
|
// and executes the action. The retail-faithful path is to
|
||||||
|
// honor MoveToObject and let ACE complete the Use server-
|
||||||
|
// side; until #63 lands, this client-side retry is the
|
||||||
|
// workaround that keeps far-range Use working.
|
||||||
|
var seq = _liveSession.NextGameActionSequence();
|
||||||
|
var body = AcDream.Core.Net.Messages.InteractRequests.BuildUse(seq, guid);
|
||||||
|
_liveSession.SendGameAction(body);
|
||||||
|
_pendingPostArrivalAction = (guid, false);
|
||||||
|
Console.WriteLine($"[B.4b] use guid=0x{guid:X8} seq={seq} (queued for arrival re-send pending #63)");
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Far range: send Use ONCE. ACE's CreateMoveToChain
|
||||||
|
// (Player_Use.cs:205) holds a callback (TryUseItem) and fires
|
||||||
|
// it server-side when WithinUseRadius passes during the
|
||||||
|
// MoveToChain poll (Player_Move.cs:150). No client-side retry
|
||||||
|
// needed — the Phase B.6 MoveToState-suppression fix
|
||||||
|
// (GameWindow.cs:6410) keeps ACE's chain alive during the
|
||||||
|
// walk.
|
||||||
|
var seq = _liveSession.NextGameActionSequence();
|
||||||
|
var body = AcDream.Core.Net.Messages.InteractRequests.BuildUse(seq, guid);
|
||||||
|
_liveSession.SendGameAction(body);
|
||||||
|
Console.WriteLine($"[B.4b] use guid=0x{guid:X8} seq={seq}");
|
||||||
|
```
|
||||||
|
|
||||||
|
(Removes the `_pendingPostArrivalAction = (guid, false);` line and trims the log to drop the `(queued for arrival re-send pending #63)` suffix.)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Delete the SendPickUp far-range retry assignment**
|
||||||
|
|
||||||
|
Find the SendPickUp method's far-range block. Search:
|
||||||
|
|
||||||
|
```
|
||||||
|
grep -n "Far range: same arrival-retry pattern" src/AcDream.App/Rendering/GameWindow.cs
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: line ~9255. Replace the block:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Far range: same arrival-retry pattern as SendUse — fire
|
||||||
|
// PickUp immediately AND queue for arrival re-send. ACE's
|
||||||
|
// first PickUp is dropped if we're outside the use-radius
|
||||||
|
// and isn't re-polled (issue #63 workaround).
|
||||||
|
var seq = _liveSession.NextGameActionSequence();
|
||||||
|
var body = AcDream.Core.Net.Messages.InteractRequests.BuildPickUp(
|
||||||
|
seq, itemGuid, _playerServerGuid, placement: 0);
|
||||||
|
_liveSession.SendGameAction(body);
|
||||||
|
_pendingPostArrivalAction = (itemGuid, true);
|
||||||
|
Console.WriteLine($"[B.5] pickup item=0x{itemGuid:X8} container=0x{_playerServerGuid:X8} seq={seq} (queued for arrival re-send pending #63)");
|
||||||
|
```
|
||||||
|
|
||||||
|
With:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Far range: send PickUp ONCE. Same auto-fire-via-MoveToChain
|
||||||
|
// callback pattern as SendUse — ACE's chain fires
|
||||||
|
// PutItemInContainer/Move server-side when in range. No
|
||||||
|
// client-side retry; Phase B.6 MoveToState suppression keeps
|
||||||
|
// ACE's chain alive.
|
||||||
|
var seq = _liveSession.NextGameActionSequence();
|
||||||
|
var body = AcDream.Core.Net.Messages.InteractRequests.BuildPickUp(
|
||||||
|
seq, itemGuid, _playerServerGuid, placement: 0);
|
||||||
|
_liveSession.SendGameAction(body);
|
||||||
|
Console.WriteLine($"[B.5] pickup item=0x{itemGuid:X8} container=0x{_playerServerGuid:X8} seq={seq}");
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Build**
|
||||||
|
|
||||||
|
```
|
||||||
|
dotnet build src/AcDream.App/AcDream.App.csproj -c Debug
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 0 errors, 0 warnings. The deletions remove the only assignment of `_pendingPostArrivalAction` for far-range paths; the close-range path still assigns it (line ~9191 and ~9258). The field declaration at line ~799 stays.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run existing tests**
|
||||||
|
|
||||||
|
```
|
||||||
|
dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj -c Debug --nologo
|
||||||
|
dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj -c Debug --nologo
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: Core.Net 294/294 pass. Core 1073/1081 pass (baseline; 8 pre-existing physics failures unchanged).
|
||||||
|
|
||||||
|
- [ ] **Step 6: Visual verification (user-driven)**
|
||||||
|
|
||||||
|
User stops any running AcDream.App gracefully via the close-window button, waits ~3 seconds, launches:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call"
|
||||||
|
$env:ACDREAM_LIVE = "1"
|
||||||
|
$env:ACDREAM_TEST_HOST = "127.0.0.1"
|
||||||
|
$env:ACDREAM_TEST_PORT = "9000"
|
||||||
|
$env:ACDREAM_TEST_USER = "testaccount"
|
||||||
|
$env:ACDREAM_TEST_PASS = "testpassword"
|
||||||
|
$env:ACDREAM_DEVTOOLS = "1"
|
||||||
|
$env:ACDREAM_PROBE_AUTOWALK = "1"
|
||||||
|
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath "launch.log"
|
||||||
|
```
|
||||||
|
|
||||||
|
Scenarios to test:
|
||||||
|
|
||||||
|
1. **Far-range Use NPC.** Double-click a Royal Guard / Pathwarden ~8 m away. Expected log shape:
|
||||||
|
```
|
||||||
|
[B.4b] use guid=0x... seq=X
|
||||||
|
[autowalk-mt] mt=0x06 isMoveTo=True ...
|
||||||
|
[autowalk-begin] dest=...
|
||||||
|
[autowalk-end] reason=arrived
|
||||||
|
```
|
||||||
|
Expected: NO `[B.4b] use-deferred` follow-up. Dialogue fires on arrival from ACE's `TryUseItem` callback.
|
||||||
|
|
||||||
|
2. **Far-range PickUp.** F-key a ground item ~5 m away. Same shape — single `[B.5] pickup` line, no `pickup-deferred`, item enters inventory.
|
||||||
|
|
||||||
|
3. **Close-range Use NPC behind player.** Within 3 m, press R. Body turns 180°. Expected log:
|
||||||
|
```
|
||||||
|
[B.4b] use deferred (close-range, turn-first) guid=0x...
|
||||||
|
[autowalk-end] reason=arrived
|
||||||
|
[B.4b] use-deferred guid=0x... seq=X
|
||||||
|
```
|
||||||
|
(Close-range deferred path is unchanged; `use-deferred` is correct here.)
|
||||||
|
|
||||||
|
4. **Open inn door from across the room.** ONE `[B.4b] use` line, no retry, door opens once.
|
||||||
|
|
||||||
|
5. **User takes manual control mid-auto-walk.** Click far NPC → press W during the walk. Auto-walk cancels (`EndServerAutoWalk("user-input")`). Action does NOT fire on arrival.
|
||||||
|
|
||||||
|
**STOP and wait for user confirmation that scenarios 1–5 pass.**
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/AcDream.App/Rendering/GameWindow.cs
|
||||||
|
git commit -m "$(cat <<'EOF'
|
||||||
|
fix(retail): suppress outbound MoveToState during inbound auto-walk
|
||||||
|
|
||||||
|
Phase B.6 — retire the Commit B issue-#63 workarounds by plugging the
|
||||||
|
underlying leak that caused them.
|
||||||
|
|
||||||
|
ApplyAutoWalkOverlay synthesizes Forward+Run input during inbound
|
||||||
|
MoveToObject so the existing motion-interpreter pipeline drives body
|
||||||
|
position + animation locally. That synth set MotionStateChanged=true,
|
||||||
|
so the outbound wire layer (GameWindow.cs:6410) built a MoveToState
|
||||||
|
packet with forwardCommand=RunForward and sent it to ACE. ACE read
|
||||||
|
the packet as "user took manual control" and cancelled its own
|
||||||
|
CreateMoveToChain (Player_Use.cs:205 → Player_Move.cs:150), so the
|
||||||
|
TryUseItem callback never fired on arrival. Our workaround sent Use
|
||||||
|
a second time at local-arrival to bypass ACE's cancelled chain.
|
||||||
|
|
||||||
|
Fix: one-line guard. The MoveToState send only fires when
|
||||||
|
!_playerController.IsServerAutoWalking. AutonomousPosition keeps
|
||||||
|
flowing during the walk (so ACE's WithinUseRadius poll sees the
|
||||||
|
player approach); ACE's chain runs uninterrupted; callback fires
|
||||||
|
when in range. Retail and holtburger (simulation.rs:178-206) follow
|
||||||
|
the same pattern — no outbound MoveToState during inbound MoveToObject.
|
||||||
|
|
||||||
|
Deletes the retry workarounds:
|
||||||
|
- SendUse far-range: `_pendingPostArrivalAction = (guid, false);`
|
||||||
|
+ the `(queued for arrival re-send pending #63)` log
|
||||||
|
- SendPickUp far-range: same shape
|
||||||
|
|
||||||
|
Close-range turn-first deferred path (separate code, retail-faithful
|
||||||
|
pre-callback rotation) is unchanged.
|
||||||
|
|
||||||
|
Spec: docs/superpowers/specs/2026-05-16-phase-b6-suppress-movetostate-during-inbound-autowalk-design.md
|
||||||
|
Plan: docs/superpowers/plans/2026-05-16-phase-b6-suppress-movetostate-during-inbound-autowalk.md
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Visual checkpoint — confirm Task 1's fix before touching cadence
|
||||||
|
|
||||||
|
This is not a code task. The Step 6 visual verification in Task 1 establishes that ACE's `MoveToChain` callback fires correctly with the MoveToState suppression in place. Only proceed to Task 3 if all five scenarios pass. If any regresses, STOP and revert Task 1's commit before continuing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Revert AP cadence to retail's narrow gate
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/AcDream.App/Input/PlayerMovementController.cs` line ~254 (field block), line ~441-449 (`NotePositionSent`), line ~1240-1275 (the gate logic), line ~289-296 (`AutoWalkArrived` doc-comment cleanup).
|
||||||
|
- Modify: `src/AcDream.App/Rendering/GameWindow.cs` line ~6450 (MTS NotePositionSent call), line ~6476 (AP NotePositionSent call).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add `_lastSentContactPlane` field**
|
||||||
|
|
||||||
|
In `PlayerMovementController.cs`, find the diff-tracking field block (around line 535-540 — search for `_lastSentPos`):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
private System.Numerics.Vector3 _lastSentPos;
|
||||||
|
private uint _lastSentCellId;
|
||||||
|
private float _lastSentTime;
|
||||||
|
private bool _lastSentInitialized;
|
||||||
|
```
|
||||||
|
|
||||||
|
Add a new field immediately after `_lastSentCellId`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
private System.Numerics.Vector3 _lastSentPos;
|
||||||
|
private uint _lastSentCellId;
|
||||||
|
private System.Numerics.Plane _lastSentContactPlane;
|
||||||
|
private float _lastSentTime;
|
||||||
|
private bool _lastSentInitialized;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Extend `NotePositionSent` to accept the contact plane**
|
||||||
|
|
||||||
|
Find `NotePositionSent` in `PlayerMovementController.cs` (around line 441). Replace:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public void NotePositionSent(System.Numerics.Vector3 worldPos,
|
||||||
|
uint cellId,
|
||||||
|
float nowSeconds)
|
||||||
|
{
|
||||||
|
_lastSentPos = worldPos;
|
||||||
|
_lastSentCellId = cellId;
|
||||||
|
_lastSentTime = nowSeconds;
|
||||||
|
_lastSentInitialized = true;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
With:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
/// <summary>
|
||||||
|
/// 2026-05-16 (Phase B.6). Called by the network outbound layer
|
||||||
|
/// after every AutonomousPosition or MoveToState that carries the
|
||||||
|
/// player's position. Resets the diff-driven heartbeat clock so the
|
||||||
|
/// next <see cref="HeartbeatDue"/> evaluation requires a fresh
|
||||||
|
/// state change. Mirrors retail's SendPositionEvent
|
||||||
|
/// (acclient_2013_pseudo_c.txt:700345-700348) which writes
|
||||||
|
/// `last_sent_position`, `last_sent_position_time`, and
|
||||||
|
/// `last_sent_contact_plane` after every send.
|
||||||
|
/// </summary>
|
||||||
|
public void NotePositionSent(System.Numerics.Vector3 worldPos,
|
||||||
|
uint cellId,
|
||||||
|
System.Numerics.Plane contactPlane,
|
||||||
|
float nowSeconds)
|
||||||
|
{
|
||||||
|
_lastSentPos = worldPos;
|
||||||
|
_lastSentCellId = cellId;
|
||||||
|
_lastSentContactPlane = contactPlane;
|
||||||
|
_lastSentTime = nowSeconds;
|
||||||
|
_lastSentInitialized = true;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Replace the per-frame gate with retail's narrow gate**
|
||||||
|
|
||||||
|
Find the cadence block in `PlayerMovementController.cs` (around line 1240-1275 — search for `retail diff-driven AP cadence`). Replace the block starting at the `// 2026-05-16 — retail diff-driven AP cadence` comment through the `HeartbeatDue =` line with:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// 2026-05-16 (Phase B.6) — retail-faithful AP cadence per
|
||||||
|
// CommandInterpreter::ShouldSendPositionEvent at
|
||||||
|
// acclient_2013_pseudo_c.txt:700233-700285. Two-branch:
|
||||||
|
//
|
||||||
|
// Branch 1 — interval NOT yet elapsed (< 1 sec since
|
||||||
|
// last send): send only if cell changed OR contact-plane
|
||||||
|
// changed (mid-walk events that matter).
|
||||||
|
//
|
||||||
|
// Branch 2 — interval HAS elapsed (>= 1 sec): send only
|
||||||
|
// if cell OR position frame changed. Truly idle = no
|
||||||
|
// send (retail's `last_sent.frame == player.frame` check
|
||||||
|
// at line 700248-700265).
|
||||||
|
//
|
||||||
|
// SendPositionEvent (line 700327) gates the actual send on
|
||||||
|
// (state & 1) != 0 && (state & 2) != 0 — Contact AND
|
||||||
|
// OnWalkable both set. We mirror that gate here so airborne
|
||||||
|
// and wall-contact-without-walkable states suppress AP
|
||||||
|
// entirely; MoveToState carries jump/fall snapshots while
|
||||||
|
// airborne.
|
||||||
|
//
|
||||||
|
// Effective rates:
|
||||||
|
// - Truly idle (grounded, no movement) : 0 Hz
|
||||||
|
// - Smooth movement (no cell/plane changes) : ~1 Hz (interval-driven)
|
||||||
|
// - Cell crossings + stair/hill steps : per-event
|
||||||
|
// - Airborne : 0 Hz
|
||||||
|
//
|
||||||
|
// Bootstrap: when NotePositionSent has never been called
|
||||||
|
// (_lastSentInitialized=false), treat every frame as
|
||||||
|
// "anything to send" so the first AP gets a chance to fire.
|
||||||
|
|
||||||
|
bool intervalElapsed = !_lastSentInitialized
|
||||||
|
|| (_simTimeSeconds - _lastSentTime) >= HeartbeatInterval;
|
||||||
|
|
||||||
|
bool cellChanged = !_lastSentInitialized
|
||||||
|
|| _lastSentCellId != CellId;
|
||||||
|
bool planeChanged = !_lastSentInitialized
|
||||||
|
|| !_lastSentContactPlane.Equals(_body.ContactPlane);
|
||||||
|
bool frameChanged = !_lastSentInitialized
|
||||||
|
|| !ApproxPositionEqual(_lastSentPos, _body.Position);
|
||||||
|
|
||||||
|
bool sendThisFrame = intervalElapsed
|
||||||
|
? (cellChanged || frameChanged)
|
||||||
|
: (cellChanged || planeChanged);
|
||||||
|
|
||||||
|
// Grounded-on-walkable gate per acclient_2013_pseudo_c.txt:700327
|
||||||
|
// (`(state & 1) != 0 && (state & 2) != 0`). Both flags must be
|
||||||
|
// set simultaneously, NOT a bitwise-OR mask test.
|
||||||
|
bool groundedOnWalkable = _body.InContact && _body.OnWalkable;
|
||||||
|
|
||||||
|
HeartbeatDue = groundedOnWalkable && sendThisFrame;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Update the MTS site to pass `contactPlane`**
|
||||||
|
|
||||||
|
In `GameWindow.cs`, find the MoveToState `NotePositionSent` call (around line 6450). Replace:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
_playerController.NotePositionSent(
|
||||||
|
worldPos: _playerController.Position,
|
||||||
|
cellId: _playerController.CellId,
|
||||||
|
nowSeconds: _playerController.SimTimeSeconds);
|
||||||
|
```
|
||||||
|
|
||||||
|
With:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
_playerController.NotePositionSent(
|
||||||
|
worldPos: _playerController.Position,
|
||||||
|
cellId: _playerController.CellId,
|
||||||
|
contactPlane: _playerController.PhysicsBody.ContactPlane,
|
||||||
|
nowSeconds: _playerController.SimTimeSeconds);
|
||||||
|
```
|
||||||
|
|
||||||
|
If `_playerController.PhysicsBody` doesn't exist as a public accessor, search:
|
||||||
|
|
||||||
|
```
|
||||||
|
grep -n "public.*_body\|public PhysicsBody\|public.*Body" src/AcDream.App/Input/PlayerMovementController.cs
|
||||||
|
```
|
||||||
|
|
||||||
|
If no public accessor, add one in `PlayerMovementController.cs` near the existing public properties (around line 130-160):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
/// <summary>2026-05-16. Read-only access to the controller's
|
||||||
|
/// physics body — needed by the network outbound layer to stamp
|
||||||
|
/// the contact plane into NotePositionSent.</summary>
|
||||||
|
public AcDream.Core.Physics.PhysicsBody PhysicsBody => _body;
|
||||||
|
```
|
||||||
|
|
||||||
|
(Verify the field name is `_body` first — search `private.*PhysicsBody`.)
|
||||||
|
|
||||||
|
- [ ] **Step 5: Update the AP site to pass `contactPlane`**
|
||||||
|
|
||||||
|
Find the AutonomousPosition `NotePositionSent` call (around line 6476). Apply the same edit:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
_playerController.NotePositionSent(
|
||||||
|
worldPos: _playerController.Position,
|
||||||
|
cellId: _playerController.CellId,
|
||||||
|
contactPlane: _playerController.PhysicsBody.ContactPlane,
|
||||||
|
nowSeconds: _playerController.SimTimeSeconds);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Build**
|
||||||
|
|
||||||
|
```
|
||||||
|
dotnet build src/AcDream.App/AcDream.App.csproj -c Debug
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 0 errors. Any compile error here is a wiring mistake — the field name (`_body` vs `_physicsBody`), the property accessor, or the `Plane` namespace.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Run existing tests**
|
||||||
|
|
||||||
|
```
|
||||||
|
dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj -c Debug --nologo
|
||||||
|
dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj -c Debug --nologo
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: Core.Net 294/294, Core 1073/1081 (baseline unchanged).
|
||||||
|
|
||||||
|
- [ ] **Step 8: Visual verification (user-driven)**
|
||||||
|
|
||||||
|
User restarts the client (graceful close + ~3 sec wait + launch). Runs scenarios:
|
||||||
|
|
||||||
|
1. **Idle test.** Stand still on flat ground in Holtburg for 10 sec. Watch the dev console / `[autowalk-up]` lines or any outbound packet trace.
|
||||||
|
- Old behavior: 1 AP/sec heartbeat.
|
||||||
|
- New behavior: ZERO outbound packets while truly idle.
|
||||||
|
|
||||||
|
2. **Smooth-running test.** Hold W and run in a straight line for 5 sec on flat ground (no cell crossings).
|
||||||
|
- Old behavior: ~60 AP/sec (per-frame while position changed).
|
||||||
|
- New behavior: ~1 AP/sec (interval-driven; cell/plane don't change every frame).
|
||||||
|
- **The character should still appear to remote observers as moving smoothly** — ACE's dead-reckoning fills in the gaps between sparse APs. If remote view becomes jittery, the cadence is too sparse and we'll need to tune.
|
||||||
|
|
||||||
|
3. **Cell-crossing test.** Run across a landblock boundary (every ~192 m). Should see a burst of AP packets at the crossing — both the `cellChanged` path and the `intervalElapsed && frameChanged` path can fire here.
|
||||||
|
|
||||||
|
4. **Far-range Use re-test.** Repeat Task 1 Step 6 scenario 1 (far-range NPC Use). Should still work — ACE's `MoveToChain` callback fires on arrival, dialogue plays, single Use packet.
|
||||||
|
|
||||||
|
5. **Hill / stair test.** Walk up a slope or stairs. Contact-plane changes per step → sub-interval AP sends fire on plane change. Behavior should look smooth to remote observers.
|
||||||
|
|
||||||
|
**STOP and wait for user confirmation that scenarios 1–5 pass.** If scenario 2 produces visible remote-jitter, the spec's `ApproxPositionEqual` epsilon may need tightening, or we may need a higher heartbeat rate; document the finding and tune before continuing.
|
||||||
|
|
||||||
|
- [ ] **Step 9: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/AcDream.App/Input/PlayerMovementController.cs src/AcDream.App/Rendering/GameWindow.cs
|
||||||
|
git commit -m "$(cat <<'EOF'
|
||||||
|
fix(retail): revert AP cadence to retail's narrow gate
|
||||||
|
|
||||||
|
Phase B.6 — closes #74. With the MoveToState suppression fix in
|
||||||
|
place, the per-frame "send while moving" cadence is no longer needed
|
||||||
|
to compensate for ACE's MoveToChain cancellation. Reverts to retail's
|
||||||
|
two-branch gate per CommandInterpreter::ShouldSendPositionEvent at
|
||||||
|
acclient_2013_pseudo_c.txt:700233-700285:
|
||||||
|
|
||||||
|
Interval NOT elapsed (< 1 sec): send if cell or contact-plane changed.
|
||||||
|
Interval elapsed (>= 1 sec): send if cell or position frame changed.
|
||||||
|
|
||||||
|
Bootstrap fires every frame until the first NotePositionSent.
|
||||||
|
Grounded-on-walkable gate (Contact && OnWalkable) unchanged from
|
||||||
|
700327.
|
||||||
|
|
||||||
|
Effective rates:
|
||||||
|
Truly idle (grounded, no movement) : 0 Hz (was 1 Hz)
|
||||||
|
Smooth straight-line run : ~1 Hz (was ~60 Hz)
|
||||||
|
Cell crossings + stair/hill steps : per-event
|
||||||
|
Airborne : 0 Hz (unchanged)
|
||||||
|
|
||||||
|
Adds _lastSentContactPlane field + extends NotePositionSent to accept
|
||||||
|
System.Numerics.Plane. Adds PhysicsBody public accessor so the wire
|
||||||
|
layer can read _body.ContactPlane to pass into NotePositionSent. Both
|
||||||
|
outbound sites (MoveToState at GameWindow.cs:6450, AP at ~6476)
|
||||||
|
updated to pass the plane.
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Visual checkpoint — confirm Task 3's cadence revert before closing issues
|
||||||
|
|
||||||
|
Same as Task 2. Only proceed to Task 5 if all five scenarios in Task 3 Step 8 pass cleanly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Close issues #63 and #74
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `docs/ISSUES.md`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Move issue #63 to "Recently closed"**
|
||||||
|
|
||||||
|
Find `## #63 — Server-initiated auto-walk (MoveToObject) not honored` in `docs/ISSUES.md` (around line 425). Currently `Status: OPEN`. Cut the entire `#63` block from the active issues section and paste it into the "Recently closed" section near the bottom of the file with these changes:
|
||||||
|
|
||||||
|
1. Change the title line from:
|
||||||
|
```markdown
|
||||||
|
## #63 — Server-initiated auto-walk (MoveToObject) not honored
|
||||||
|
```
|
||||||
|
to:
|
||||||
|
```markdown
|
||||||
|
## #63 — [DONE 2026-05-16 · `<TASK1_SHA>`] Server-initiated auto-walk (MoveToObject) not honored
|
||||||
|
```
|
||||||
|
(Replace `<TASK1_SHA>` with the actual commit SHA from Task 1. Get it via `git log --oneline -5`.)
|
||||||
|
|
||||||
|
2. Change `Status: OPEN` to `Status: DONE`.
|
||||||
|
|
||||||
|
3. Append a "Resolution" paragraph after the existing "Acceptance":
|
||||||
|
```markdown
|
||||||
|
**Resolution (2026-05-16):** B.6 slice 2 (2026-05-14) shipped the inbound-MoveToObject auto-walk handling. The remaining "MoveToChain callback never fires on arrival" half was tracked to ApplyAutoWalkOverlay's synthesized Forward+Run input leaking to the wire as an outbound MoveToState packet (forwardCommand=RunForward), which ACE read as "user took manual control" and used to cancel its own MoveToChain. Fix in `<TASK1_SHA>` adds a single guard at `GameWindow.cs:6410`: outbound MoveToState only fires when `!_playerController.IsServerAutoWalking`. With ACE's chain running uninterrupted, the `TryUseItem` callback (Player_Use.cs:205) fires server-side on arrival; no client retry needed. Retired the `_pendingPostArrivalAction` retry workarounds from SendUse + SendPickUp far-range paths.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Move issue #74 to "Recently closed"**
|
||||||
|
|
||||||
|
Find `## #74 — AP cadence is per-frame-while-moving, more chatty than retail`. Same shape: cut the block, paste in "Recently closed", change title to:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## #74 — [DONE 2026-05-16 · `<TASK3_SHA>`] AP cadence is per-frame-while-moving, more chatty than retail
|
||||||
|
```
|
||||||
|
|
||||||
|
Change `Status: OPEN` to `Status: DONE`. Append:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
**Resolution (2026-05-16):** With #63 closed (MoveToState no longer cancels ACE's MoveToChain), the per-frame-while-moving cadence workaround is unnecessary. Reverted to retail's two-branch ShouldSendPositionEvent gate per `acclient_2013_pseudo_c.txt:700233-700285` in `<TASK3_SHA>`. Effective rate during smooth motion drops from ~60 Hz to ~1 Hz; truly idle drops from 1 Hz to 0 Hz. Cell crossings + contact-plane changes still fire mid-interval. Matches retail bit-for-bit.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add docs/ISSUES.md
|
||||||
|
git commit -m "$(cat <<'EOF'
|
||||||
|
docs: close #63 (MoveToObject not honored) + #74 (AP chattier than retail)
|
||||||
|
|
||||||
|
Both retired by Phase B.6. #63 fixed in <TASK1_SHA> (MoveToState
|
||||||
|
suppression during inbound auto-walk + retry workaround retirement).
|
||||||
|
#74 fixed in <TASK3_SHA> (AP cadence reverted to retail's two-branch
|
||||||
|
ShouldSendPositionEvent gate now that the workaround that needed
|
||||||
|
per-frame APs is gone).
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
```
|
||||||
|
|
||||||
|
(Replace `<TASK1_SHA>` and `<TASK3_SHA>` with the actual SHAs.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-review checklist
|
||||||
|
|
||||||
|
**Spec coverage:**
|
||||||
|
|
||||||
|
| Spec section | Plan task |
|
||||||
|
|---|---|
|
||||||
|
| Wire-level changes: `IsServerAutoWalking` guard at `GameWindow.cs:6410` | Task 1 Step 1 ✅ |
|
||||||
|
| Far-range Use retry deletion | Task 1 Step 2 ✅ |
|
||||||
|
| Far-range PickUp retry deletion | Task 1 Step 3 ✅ |
|
||||||
|
| Log string cleanup | Task 1 Steps 2+3 ✅ |
|
||||||
|
| `_lastSentContactPlane` field + `NotePositionSent` signature | Task 3 Steps 1+2 ✅ |
|
||||||
|
| Retail-narrow gate | Task 3 Step 3 ✅ |
|
||||||
|
| MTS site contactPlane wiring | Task 3 Step 4 ✅ |
|
||||||
|
| AP site contactPlane wiring | Task 3 Step 5 ✅ |
|
||||||
|
| Testing plan (visual scenarios) | Task 1 Step 6 + Task 3 Step 8 ✅ |
|
||||||
|
| Close `#63` + `#74` | Task 5 ✅ |
|
||||||
|
| Out-of-scope `#75` (status messages) | Filed as deferred — not in this plan ✅ |
|
||||||
|
|
||||||
|
**Placeholder scan:** No "TBD" / "implement later" / vague phrases. Every step has actual code or actual commands.
|
||||||
|
|
||||||
|
**Type consistency:**
|
||||||
|
- `IsServerAutoWalking` referenced Task 1 Step 1 — already exists in code (verified at PlayerMovementController.cs:273). ✅
|
||||||
|
- `_lastSentContactPlane : System.Numerics.Plane` defined Task 3 Step 1, used Task 3 Steps 2+3. ✅
|
||||||
|
- `NotePositionSent(Vector3, uint, Plane, float)` defined Task 3 Step 2, called Task 3 Steps 4+5. ✅
|
||||||
|
- `_playerController.PhysicsBody` property defined Task 3 Step 4 (conditional add if missing), used Task 3 Steps 4+5. ✅
|
||||||
|
- `_lastSentPos`, `_lastSentCellId`, `_lastSentTime`, `_lastSentInitialized` — pre-existing from Commit B. ✅
|
||||||
|
|
||||||
|
**Risk / rollback:**
|
||||||
|
|
||||||
|
- Task 1 commit: simple revert restores the workaround.
|
||||||
|
- Task 3 commit: simple revert restores the per-frame cadence.
|
||||||
|
- Task 5 commit: docs-only; trivial.
|
||||||
|
|
||||||
|
Each task is independently revertable. If Task 3 introduces remote-view jitter (scenario 2), revert Task 3 and re-evaluate (e.g., dial down `HeartbeatInterval` from 1 s to 0.5 s).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Execution handoff
|
||||||
|
|
||||||
|
Plan saved to `docs/superpowers/plans/2026-05-16-phase-b6-suppress-movetostate-during-inbound-autowalk.md`.
|
||||||
|
|
||||||
|
Two options for the controller:
|
||||||
|
|
||||||
|
1. **Subagent-Driven (recommended)** — Dispatch fresh subagent per task. Two-stage review (spec compliance + code quality) between tasks. ~3 implementer + 6 reviewer dispatches.
|
||||||
|
|
||||||
|
2. **Inline Execution** — Execute tasks in this session using `superpowers:executing-plans`. Two visual-verify checkpoints (between Task 1 & Task 3, between Task 3 & Task 5).
|
||||||
|
|
||||||
|
Given the small scope (~30 LOC of behavior change + docs) and the two mandatory user-driven visual checkpoints, inline execution may be simpler — the subagent overhead exceeds the implementation time for tasks this small.
|
||||||
|
|
@ -0,0 +1,156 @@
|
||||||
|
# Phase B.6 — Suppress outbound `MoveToState` during inbound MoveToObject auto-walk
|
||||||
|
|
||||||
|
**Date:** 2026-05-16
|
||||||
|
**Closes:** [#63](../../ISSUES.md) (server-initiated auto-walk not honored — the half about ACE's `MoveToChain` callback never firing), [#74](../../ISSUES.md) (AP cadence chattier than retail)
|
||||||
|
**Milestone:** M1 — Walkable + clickable world
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Make ACE's server-side `MoveToChain` callback fire the Use action on arrival, eliminating the client-side retry workaround that today's `feat(retail): Commit B` (`b5da17d`) restored as an issue-#63 mitigation.
|
||||||
|
|
||||||
|
## Background
|
||||||
|
|
||||||
|
When the player double-clicks an NPC across the room in our current build:
|
||||||
|
|
||||||
|
1. We send `Use(guid)` wire packet immediately at click.
|
||||||
|
2. ACE: player not in `WithinUseRadius` → server-side `CreateMoveToChain(item, (success) => TryUseItem(item, success))` ([`Player_Use.cs:205`](../../../references/ACE/Source/ACE.Server/WorldObjects/Player_Use.cs)).
|
||||||
|
3. ACE broadcasts `UpdateMotion` with `MovementType=6` (MoveToObject) targeting our player.
|
||||||
|
4. Our client honors it via `BeginServerAutoWalk(...)` at [`GameWindow.cs:3389-3414`](../../../src/AcDream.App/Rendering/GameWindow.cs).
|
||||||
|
5. The auto-walk overlay at [`PlayerMovementController.ApplyAutoWalkOverlay`](../../../src/AcDream.App/Input/PlayerMovementController.cs) synthesizes `input.Forward = true, input.Run = true` (line 611-619) so the rest of `Update` runs as if the user pressed W.
|
||||||
|
6. **Bug:** the rest of `Update` computes `MotionStateChanged = true` and the outbound wire layer at [`GameWindow.cs:6410-6445`](../../../src/AcDream.App/Rendering/GameWindow.cs) builds and sends a `MoveToState` packet with `forwardCommand=RunForward, holdKey=Run`.
|
||||||
|
7. ACE reads that as **user took manual control** and cancels its own `MoveToChain` — the `TryUseItem` callback never fires.
|
||||||
|
8. We've worked around this by sending `Use` a second time on local-arrival (`_pendingPostArrivalAction` in [`GameWindow.SendUse` far-range path](../../../src/AcDream.App/Rendering/GameWindow.cs)). ACE accepts the second Use because we're now in range. **This is what we're retiring.**
|
||||||
|
|
||||||
|
The retail client and holtburger's Rust client don't have this bug: neither synthesizes player-input MoveToState during inbound MoveToObject. Their auto-walk is body-level only; ACE's chain runs uninterrupted; the callback fires on arrival.
|
||||||
|
|
||||||
|
### Retail anchor
|
||||||
|
|
||||||
|
- ACE `Player_Use.cs:205` — `CreateMoveToChain(item, (success) => TryUseItem(item, success))`.
|
||||||
|
- ACE `Player_Move.cs:150` — chain polls and fires `callback(true)` when within use radius.
|
||||||
|
- Holtburger [`simulation.rs:178-206`](../../../references/holtburger/crates/holtburger-core/src/client/simulation.rs) — `MoveToObject` handler returns `Ok(Vec::new())`, no outbound packets in response. No retry mechanism anywhere in its codebase.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
Single-flag suppression of the outbound `MoveToState` send while a server-initiated auto-walk is active. The body keeps walking locally (synth still drives the animation cycle + per-frame position update), `AutonomousPosition` keeps flowing (so ACE's `WithinUseRadius` poll sees us approach), but the misleading "user is RunForward" packet is silenced.
|
||||||
|
|
||||||
|
### Wire-level changes
|
||||||
|
|
||||||
|
**File: [`src/AcDream.App/Input/PlayerMovementController.cs`](../../../src/AcDream.App/Input/PlayerMovementController.cs)**
|
||||||
|
|
||||||
|
No new field needed — [`IsServerAutoWalking`](../../../src/AcDream.App/Input/PlayerMovementController.cs:273) already exists, added by B.6 slice 2 (2026-05-14) for this purpose. Backs onto private `_autoWalkActive` (line 254). Set true by `BeginServerAutoWalk`, false by `EndServerAutoWalk`. User-input cancellation at line 478-482 already clears the flag, so manual takeover transitions correctly to "user is in control, send MoveToState normally."
|
||||||
|
|
||||||
|
**File: [`src/AcDream.App/Rendering/GameWindow.cs`](../../../src/AcDream.App/Rendering/GameWindow.cs)**
|
||||||
|
|
||||||
|
Guard the `MoveToState.Build` block at lines 6410-6445 with a second condition:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
if (result.MotionStateChanged && !_playerController.IsServerAutoWalking)
|
||||||
|
{
|
||||||
|
// ... existing MoveToState build + SendGameAction ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
No other path needs adjustment; the AutonomousPosition path remains unchanged (heartbeat-driven, gated on `Contact && OnWalkable`).
|
||||||
|
|
||||||
|
Single-clause add; `IsServerAutoWalking` already documented at line 267-273 as the predicate for "the next Update synthesizes Forward+Run."
|
||||||
|
|
||||||
|
### Workaround retirement
|
||||||
|
|
||||||
|
After the wire fix is verified, the following Commit B safeguards are deleted:
|
||||||
|
|
||||||
|
1. **Far-range Use retry.** In [`GameWindow.SendUse`](../../../src/AcDream.App/Rendering/GameWindow.cs) (~line 9201-9204), remove the `_pendingPostArrivalAction = (guid, false);` assignment from the far-range path and rewrite the comment block to say "ACE auto-fires Use via MoveToChain callback; client sends Use exactly once."
|
||||||
|
|
||||||
|
2. **Far-range PickUp retry.** Same shape in [`GameWindow.SendPickUp`](../../../src/AcDream.App/Rendering/GameWindow.cs) (~line 9265).
|
||||||
|
|
||||||
|
3. **Log strings.** Drop `(queued for arrival re-send pending #63)` from the same paths.
|
||||||
|
|
||||||
|
4. **AP cadence revert.** Replace the per-frame `positionChanged` gate in [`PlayerMovementController.cs:1261-1275`](../../../src/AcDream.App/Input/PlayerMovementController.cs:1261) with retail's narrow gate per `acclient_2013_pseudo_c.txt:700233-700285`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// During sub-interval: send only on cell-or-contact-plane change.
|
||||||
|
// After interval elapses: send only if position/frame changed.
|
||||||
|
// Retail-faithful per acclient_2013_pseudo_c.txt:700233 ShouldSendPositionEvent.
|
||||||
|
bool intervalElapsed = !_lastSentInitialized
|
||||||
|
|| (_simTimeSeconds - _lastSentTime) >= HeartbeatInterval;
|
||||||
|
|
||||||
|
bool cellChanged = _lastSentCellId != CellId;
|
||||||
|
bool planeChanged = !_lastSentContactPlane.Equals(_body.ContactPlane);
|
||||||
|
bool frameChanged = !ApproxPositionEqual(_lastSentPos, _body.Position);
|
||||||
|
|
||||||
|
bool sendThisFrame = intervalElapsed
|
||||||
|
? (cellChanged || frameChanged)
|
||||||
|
: (cellChanged || planeChanged);
|
||||||
|
|
||||||
|
HeartbeatDue = _body.InContact && _body.OnWalkable && sendThisFrame;
|
||||||
|
```
|
||||||
|
|
||||||
|
`_lastSentContactPlane` requires adding a `System.Numerics.Plane _lastSentContactPlane;` field next to the other `_lastSent*` fields. `System.Numerics.Plane` is a struct with `IEquatable<Plane>` so direct `!_lastSentContactPlane.Equals(_body.ContactPlane)` works. Extend `NotePositionSent` to accept a `Plane` and stamp it alongside position/cell/time. The outbound layer at `GameWindow.cs:6410-6445` (MoveToState) and `~line 6310` (AutonomousPosition) both gain a `contactPlane: _body.ContactPlane` argument.
|
||||||
|
|
||||||
|
### Issues closed
|
||||||
|
|
||||||
|
- **#63** — server-initiated auto-walk not honored. (Both halves: B.6 slice 2 shipped the inbound handling on 2026-05-14; this commit fixes the MoveToChain callback-cancellation gap.)
|
||||||
|
- **#74** — AP cadence chattier than retail. (Direct revert to retail's narrow gate enabled by the workaround retirement.)
|
||||||
|
|
||||||
|
## Out of scope (file as new issues)
|
||||||
|
|
||||||
|
- **Retail status messages** ("Approaching {target}" / "Using the {target}" on screen). Client-side UX polish; not wire-critical. Mentioned by user during brainstorm 2026-05-16 as nice-to-have to match retail feel. File as `#75 — Retail-faithful pending-action status messages on screen`.
|
||||||
|
|
||||||
|
## Testing plan
|
||||||
|
|
||||||
|
### Unit tests
|
||||||
|
|
||||||
|
No new unit tests required. The behavioral change is wire-level integration; existing Core.Net 294 baseline must hold. Existing 1073 Core tests baseline must hold (8 pre-existing physics failures unchanged).
|
||||||
|
|
||||||
|
### Visual verification (user-driven)
|
||||||
|
|
||||||
|
1. **Far-range Use NPC.** Double-click a Pathwarden / Royal Guard ~8-15 m away. Expected log shape:
|
||||||
|
```
|
||||||
|
[B.4b] use guid=0x... seq=X
|
||||||
|
[autowalk-mt] mt=0x06 isMoveTo=True ...
|
||||||
|
[autowalk-begin] dest=...
|
||||||
|
[autowalk-end] reason=arrived
|
||||||
|
```
|
||||||
|
**Expected: NO `[B.4b] use-deferred` line.** Dialogue fires from ACE's callback. One Use packet on the wire, not two.
|
||||||
|
|
||||||
|
2. **Far-range PickUp item.** F-key a ground item ~5-10 m away. Same shape — one PickUp, no retry.
|
||||||
|
|
||||||
|
3. **Close-range Use NPC behind player.** Within 3 m of an NPC facing away, press R. Body turns 180°, then dialogue fires. The close-range deferred path is unchanged by this work; should still produce:
|
||||||
|
```
|
||||||
|
[B.4b] use deferred (close-range, turn-first)
|
||||||
|
[autowalk-end] reason=arrived
|
||||||
|
[B.4b] use-deferred
|
||||||
|
```
|
||||||
|
(`use-deferred` is correct here — close-range deferral is retail-faithful turn-first, not a workaround.)
|
||||||
|
|
||||||
|
4. **Open inn door from across the room.** Walks, opens. ONE `[B.4b] use` line, no retry, no double-open.
|
||||||
|
|
||||||
|
5. **User takes manual control mid-auto-walk.** Click far NPC → during the walk, press W. Auto-walk cancels via `user-input`, normal MoveToState resumes firing. The action does NOT fire on arrival (user cancelled).
|
||||||
|
|
||||||
|
6. **Rapid click between two targets.** Click NPC, before arrival click a second NPC. ACE re-broadcasts MoveToObject with the new target. Our overlay re-targets (existing `BeginServerAutoWalk` overwrite). Action fires on arrival at the SECOND target. No stale `_pendingPostArrivalAction` from the first click (close-range path keeps its handling; far-range no longer queues anything).
|
||||||
|
|
||||||
|
### Pre-conditions
|
||||||
|
|
||||||
|
- ACE running on `127.0.0.1:9000`.
|
||||||
|
- `+Acdream` character at Holtburg (test character).
|
||||||
|
- `ACDREAM_PROBE_AUTOWALK=1` for the trace lines.
|
||||||
|
- Build green: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug`.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- All six visual-verify scenarios pass.
|
||||||
|
- No `use-deferred` log line for FAR-range Use (only for close-range turn-first defer, which is correct).
|
||||||
|
- Core.Net tests pass (294/294).
|
||||||
|
- Core tests baseline holds (1073/1081).
|
||||||
|
- Issues #63 and #74 close with this commit's SHA.
|
||||||
|
|
||||||
|
## Risk + rollback
|
||||||
|
|
||||||
|
**Risk:** If ACE's `MoveToChain` somehow STILL doesn't fire the callback (e.g., a separate ACE bug we haven't identified), Use action breaks for far-range targets. The user would observe "walked to NPC, no dialogue."
|
||||||
|
|
||||||
|
**Rollback:** trivial git revert. The fix is one guard clause + workaround deletions. Reverting restores the working-but-chatty Commit B state at `b5da17d`.
|
||||||
|
|
||||||
|
**Detection during visual verify:** if scenario 1 fails (no dialogue after arrival), revert before merging.
|
||||||
|
|
||||||
|
## Plan handoff
|
||||||
|
|
||||||
|
This design is the input for `superpowers:writing-plans`, which produces the task-by-task plan with code blocks ready to paste.
|
||||||
|
|
@ -154,6 +154,16 @@ public sealed class PlayerMovementController
|
||||||
/// <summary>Full 3D world-space velocity of the physics body. Exposed for diagnostic logging.</summary>
|
/// <summary>Full 3D world-space velocity of the physics body. Exposed for diagnostic logging.</summary>
|
||||||
public Vector3 BodyVelocity => _body.Velocity;
|
public Vector3 BodyVelocity => _body.Velocity;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 2026-05-16 — current contact plane (normal + distance) for the
|
||||||
|
/// physics body. Exposed so the network outbound layer can stamp
|
||||||
|
/// it into <see cref="NotePositionSent"/> for retail's diff-driven
|
||||||
|
/// AP cadence: SendPositionEvent re-sends if cell OR contact-plane
|
||||||
|
/// changed since last_sent, per
|
||||||
|
/// <c>acclient_2013_pseudo_c.txt:700233 ShouldSendPositionEvent</c>.
|
||||||
|
/// </summary>
|
||||||
|
public System.Numerics.Plane ContactPlane => _body.ContactPlane;
|
||||||
|
|
||||||
// Jump charge state.
|
// Jump charge state.
|
||||||
private bool _jumpCharging;
|
private bool _jumpCharging;
|
||||||
private float _jumpExtent;
|
private float _jumpExtent;
|
||||||
|
|
@ -203,6 +213,7 @@ public sealed class PlayerMovementController
|
||||||
|
|
||||||
private System.Numerics.Vector3 _lastSentPos;
|
private System.Numerics.Vector3 _lastSentPos;
|
||||||
private uint _lastSentCellId;
|
private uint _lastSentCellId;
|
||||||
|
private System.Numerics.Plane _lastSentContactPlane;
|
||||||
private float _lastSentTime;
|
private float _lastSentTime;
|
||||||
private bool _lastSentInitialized;
|
private bool _lastSentInitialized;
|
||||||
private float _simTimeSeconds;
|
private float _simTimeSeconds;
|
||||||
|
|
@ -256,22 +267,51 @@ public sealed class PlayerMovementController
|
||||||
private float _autoWalkMinDistance;
|
private float _autoWalkMinDistance;
|
||||||
private float _autoWalkDistanceToObject;
|
private float _autoWalkDistanceToObject;
|
||||||
private bool _autoWalkMoveTowards;
|
private bool _autoWalkMoveTowards;
|
||||||
// Walk-vs-run decision is made ONCE at BeginServerAutoWalk based on
|
// 2026-05-16 (retail-faithful) — walk-vs-run is a ONE-SHOT
|
||||||
// initial distance vs the wire's WalkRunThreshold, then held for the
|
// decision at chain start. Per user observation 2026-05-16: if
|
||||||
// duration of the auto-walk. Earlier per-frame evaluation produced
|
// initial distance is at or above the walk-run threshold, the
|
||||||
// "runs partway then walks the rest" which the user reported as
|
// body runs all the way to the target; otherwise it walks all
|
||||||
// wrong: the character should run all the way to the target then
|
// the way. No per-frame switching as the player closes distance.
|
||||||
// stop, not transition to a walk near the end.
|
//
|
||||||
|
// Formula matches retail's MovementParameters::get_command
|
||||||
|
// (decomp 0x0052aa00, line 308000+):
|
||||||
|
// running = (initialDist - distance_to_object) >= walk_run_threshhold
|
||||||
|
// The "distance left to walk" (current minus use-radius) is
|
||||||
|
// compared against the wire-supplied threshold (15m default,
|
||||||
|
// retail constant at 0x005243b5). The retail function reads
|
||||||
|
// `arg2` as the current distance but in practice is called at
|
||||||
|
// chain setup with the initial distance, and the resulting
|
||||||
|
// decision is cached for the rest of the chain — matching the
|
||||||
|
// user-observed "run all the way / walk all the way" behaviour.
|
||||||
private bool _autoWalkInitiallyRunning;
|
private bool _autoWalkInitiallyRunning;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// True while a server-initiated auto-walk (MoveToObject inbound) is
|
/// True while a server-initiated auto-walk (MoveToObject inbound) is
|
||||||
/// active on the local player. The next <see cref="Update"/> call
|
/// active on the local player. Update drives the body's velocity
|
||||||
/// synthesizes Forward+Run input and steers <see cref="Yaw"/> toward
|
/// and motion state machine DIRECTLY from the wire-supplied path
|
||||||
/// the destination until arrival or user-input cancellation.
|
/// data, NOT via synthesized player-input. The
|
||||||
|
/// motion-state-change detection downstream sees no user input
|
||||||
|
/// during auto-walk, so no MoveToState wire packet is built — ACE's
|
||||||
|
/// server-side MoveToChain can run uninterrupted until its callback
|
||||||
|
/// fires.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool IsServerAutoWalking => _autoWalkActive;
|
public bool IsServerAutoWalking => _autoWalkActive;
|
||||||
|
|
||||||
|
// 2026-05-16 (issue #75) — tracks whether the auto-walk overlay is
|
||||||
|
// actually advancing the body this frame. False during the
|
||||||
|
// turn-first phase (rotating in place toward target) and after
|
||||||
|
// arrival. Drives the animation cycle override: walking animation
|
||||||
|
// only plays when the body is actually moving forward.
|
||||||
|
private bool _autoWalkMovingForwardThisFrame;
|
||||||
|
|
||||||
|
// 2026-05-16 (issue #69 fix) — turn direction this frame.
|
||||||
|
// +1 = rotating counter-clockwise (Yaw increasing) → TurnLeft cycle
|
||||||
|
// -1 = rotating clockwise (Yaw decreasing) → TurnRight cycle
|
||||||
|
// 0 = aligned or not turning
|
||||||
|
// Drives the animation cycle override during turn-first phase so
|
||||||
|
// the body plays the actual turn animation instead of statue-pivoting.
|
||||||
|
private int _autoWalkTurnDirectionThisFrame;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Fires once when an auto-walk reaches its destination naturally
|
/// Fires once when an auto-walk reaches its destination naturally
|
||||||
/// (i.e. <see cref="EndServerAutoWalk"/> called with
|
/// (i.e. <see cref="EndServerAutoWalk"/> called with
|
||||||
|
|
@ -398,17 +438,35 @@ public sealed class PlayerMovementController
|
||||||
_autoWalkDistanceToObject = distanceToObject;
|
_autoWalkDistanceToObject = distanceToObject;
|
||||||
_autoWalkMoveTowards = moveTowards;
|
_autoWalkMoveTowards = moveTowards;
|
||||||
|
|
||||||
// Decide run vs walk ONCE based on the initial horizontal
|
// 2026-05-16 (retail-faithful) — one-shot walk/run decision
|
||||||
// distance from the player to the destination. Run-all-the-way
|
// using retail's get_command formula (decomp 0x0052aa00):
|
||||||
// is the retail-faithful behaviour the user observed: pick a
|
// running = (initialDist - distance_to_object) >= walk_run_threshhold
|
||||||
// distant target → character runs the whole way, decelerates
|
// Subtract the use-radius from the raw distance so the
|
||||||
// to a stop at use radius. Earlier per-frame evaluation made
|
// discriminator is "distance LEFT to walk" — same shape as
|
||||||
// the body transition to a walk inside threshold and felt
|
// retail's `arg2 - distance_to_object` term. Held for the
|
||||||
// wrong (the user reported "runs partway then walks").
|
// rest of the auto-walk so the body runs all the way to the
|
||||||
|
// target (or walks all the way), matching observed retail
|
||||||
|
// behaviour.
|
||||||
|
//
|
||||||
|
// The wire-supplied walkRunThreshold defaults to 15m at
|
||||||
|
// retail's MovementParameters constructor (0x005243b5) AND at
|
||||||
|
// ACE's wire-layer MoveToParameters.cs:51 — but the user-
|
||||||
|
// observed retail behaviour is "only walk when very close,"
|
||||||
|
// run from any non-trivial distance. ACE's own physics layer
|
||||||
|
// confirms: MovementParameters.cs:49-50 has the 15.0f default
|
||||||
|
// commented out with `Default_WalkRunThreshold = 1.0f`
|
||||||
|
// active, and Creature.cs:312 comments "default 15 distance
|
||||||
|
// seems too far" before halving it to 7.5f for CanCharge.
|
||||||
|
// We override the wire value with a retail-faithful 1.0m
|
||||||
|
// constant; the wire value (typically 15m from ACE) is
|
||||||
|
// ignored for the run/walk decision. _walkRunThreshold is in
|
||||||
|
// METERS of remaining-distance-to-use-radius.
|
||||||
|
const float RetailWalkRunThresholdMeters = 1.0f;
|
||||||
float dx = destinationWorld.X - _body.Position.X;
|
float dx = destinationWorld.X - _body.Position.X;
|
||||||
float dy = destinationWorld.Y - _body.Position.Y;
|
float dy = destinationWorld.Y - _body.Position.Y;
|
||||||
float initialDist = MathF.Sqrt(dx * dx + dy * dy);
|
float initialDist = MathF.Sqrt(dx * dx + dy * dy);
|
||||||
_autoWalkInitiallyRunning = initialDist > walkRunThreshold;
|
float remainingAtStart = initialDist - distanceToObject;
|
||||||
|
_autoWalkInitiallyRunning = remainingAtStart >= RetailWalkRunThresholdMeters;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -431,19 +489,21 @@ public sealed class PlayerMovementController
|
||||||
/// 2026-05-16. Called by the network outbound layer after every
|
/// 2026-05-16. Called by the network outbound layer after every
|
||||||
/// AutonomousPosition or MoveToState that carries the player's
|
/// AutonomousPosition or MoveToState that carries the player's
|
||||||
/// position. Resets the diff-driven heartbeat clock so the next
|
/// position. Resets the diff-driven heartbeat clock so the next
|
||||||
/// `HeartbeatDue` evaluation requires either a fresh position
|
/// `HeartbeatDue` evaluation requires either a fresh state change
|
||||||
/// change OR another full HeartbeatInterval. Mirrors retail's
|
/// (cell, contact-plane, or frame) OR another full HeartbeatInterval.
|
||||||
/// SendPositionEvent (0x006b4770) which updates
|
/// Mirrors retail's SendPositionEvent at
|
||||||
/// `last_sent_position_time` + `last_sent_position` at every
|
/// <c>acclient_2013_pseudo_c.txt:700345-700348</c> which updates
|
||||||
/// send, AND SendMovementEvent (0x006b4680) which also touches
|
/// `last_sent_position`, `last_sent_position_time`, AND
|
||||||
/// the same shared clock (both consumers of the 1 sec window).
|
/// `last_sent_contact_plane` after every send.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void NotePositionSent(System.Numerics.Vector3 worldPos,
|
public void NotePositionSent(System.Numerics.Vector3 worldPos,
|
||||||
uint cellId,
|
uint cellId,
|
||||||
|
System.Numerics.Plane contactPlane,
|
||||||
float nowSeconds)
|
float nowSeconds)
|
||||||
{
|
{
|
||||||
_lastSentPos = worldPos;
|
_lastSentPos = worldPos;
|
||||||
_lastSentCellId = cellId;
|
_lastSentCellId = cellId;
|
||||||
|
_lastSentContactPlane = contactPlane;
|
||||||
_lastSentTime = nowSeconds;
|
_lastSentTime = nowSeconds;
|
||||||
_lastSentInitialized = true;
|
_lastSentInitialized = true;
|
||||||
}
|
}
|
||||||
|
|
@ -465,9 +525,33 @@ public sealed class PlayerMovementController
|
||||||
/// <c>distanceToObject</c>; flee arrives at <c>minDistance</c>.
|
/// <c>distanceToObject</c>; flee arrives at <c>minDistance</c>.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private MovementInput ApplyAutoWalkOverlay(float dt, MovementInput input)
|
/// <summary>
|
||||||
|
/// 2026-05-16 (issue #75 refactor) — drive the body directly from
|
||||||
|
/// the wire-supplied path data during server-initiated auto-walk,
|
||||||
|
/// without synthesizing player-input. Replaces the earlier
|
||||||
|
/// ApplyAutoWalkOverlay which returned a synthesized Forward+Run
|
||||||
|
/// MovementInput; that synthesis leaked to the wire as an outbound
|
||||||
|
/// MoveToState packet ("user is RunForward") which ACE read as
|
||||||
|
/// user-took-manual-control and cancelled its own MoveToChain. The
|
||||||
|
/// architecture now mirrors retail's MovementManager::PerformMovement
|
||||||
|
/// case 6 (decomp 0x00524440): step the body's velocity + motion
|
||||||
|
/// state directly; the user-input pipeline downstream sees no input
|
||||||
|
/// because the user didn't press anything, so no MoveToState gets
|
||||||
|
/// built.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Returns <c>true</c> when this method consumed motion control for
|
||||||
|
/// the frame (auto-walk active, no user override, no arrival).
|
||||||
|
/// Caller (<see cref="Update"/>) must skip the user-input motion +
|
||||||
|
/// body-velocity sections to avoid them overriding the auto-walk's
|
||||||
|
/// velocity assignment.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
private bool DriveServerAutoWalk(float dt, MovementInput input)
|
||||||
{
|
{
|
||||||
if (!_autoWalkActive) return input;
|
_autoWalkMovingForwardThisFrame = false;
|
||||||
|
_autoWalkTurnDirectionThisFrame = 0;
|
||||||
|
if (!_autoWalkActive) return false;
|
||||||
|
|
||||||
// User-input cancellation. Any direct movement key takes over.
|
// User-input cancellation. Any direct movement key takes over.
|
||||||
// Mouse-only turning (no movement key) doesn't cancel — the
|
// Mouse-only turning (no movement key) doesn't cancel — the
|
||||||
|
|
@ -478,7 +562,7 @@ public sealed class PlayerMovementController
|
||||||
if (userOverride)
|
if (userOverride)
|
||||||
{
|
{
|
||||||
EndServerAutoWalk("user-input");
|
EndServerAutoWalk("user-input");
|
||||||
return input;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Horizontal distance to target — server owns Z, our local body
|
// Horizontal distance to target — server owns Z, our local body
|
||||||
|
|
@ -551,16 +635,26 @@ public sealed class PlayerMovementController
|
||||||
// MathF.Min(|delta|, maxStep) naturally clamps the final
|
// MathF.Min(|delta|, maxStep) naturally clamps the final
|
||||||
// fractional step to exactly delta, so we land on the
|
// fractional step to exactly delta, so we land on the
|
||||||
// target heading without overshoot.
|
// target heading without overshoot.
|
||||||
// 2026-05-16 — retail-faithful turn rate. Auto-walk knows
|
// 2026-05-16 — retail-faithful turn rate. Auto-walk's
|
||||||
// its run/walk decision from _autoWalkInitiallyRunning
|
// run/walk decision (one-shot at chain start) drives the
|
||||||
// (set at BeginServerAutoWalk based on initial distance vs
|
// turn rate: running rotation is 50% faster per
|
||||||
// WalkRunThreshold). Running rotation is 50% faster per
|
|
||||||
// run_turn_factor at retail 0x007c8914.
|
// run_turn_factor at retail 0x007c8914.
|
||||||
float maxStep = RemoteMoveToDriver.TurnRateFor(_autoWalkInitiallyRunning) * dt;
|
float maxStep = RemoteMoveToDriver.TurnRateFor(_autoWalkInitiallyRunning) * dt;
|
||||||
Yaw += MathF.Sign(delta) * MathF.Min(MathF.Abs(delta), maxStep);
|
float yawStep = MathF.Sign(delta) * MathF.Min(MathF.Abs(delta), maxStep);
|
||||||
|
Yaw += yawStep;
|
||||||
while (Yaw > MathF.PI) Yaw -= 2f * MathF.PI;
|
while (Yaw > MathF.PI) Yaw -= 2f * MathF.PI;
|
||||||
while (Yaw < -MathF.PI) Yaw += 2f * MathF.PI;
|
while (Yaw < -MathF.PI) Yaw += 2f * MathF.PI;
|
||||||
|
|
||||||
|
// 2026-05-16 (issue #69) — record rotation direction so the
|
||||||
|
// animation override can pick the TurnLeft/TurnRight cycle.
|
||||||
|
// Sign convention matches user-driven A/D in Update:
|
||||||
|
// yawStep > 0 ⇔ TurnLeft (Yaw increases)
|
||||||
|
// yawStep < 0 ⇔ TurnRight (Yaw decreases)
|
||||||
|
// Small dead-zone avoids flickering between Turn cycles
|
||||||
|
// when the residual delta is effectively zero.
|
||||||
|
if (MathF.Abs(yawStep) > 1e-5f)
|
||||||
|
_autoWalkTurnDirectionThisFrame = yawStep > 0f ? +1 : -1;
|
||||||
|
|
||||||
// Two alignment thresholds:
|
// Two alignment thresholds:
|
||||||
// walkWhileTurning (30°): outside this, body turns in place.
|
// walkWhileTurning (30°): outside this, body turns in place.
|
||||||
// Inside, body walks forward while
|
// Inside, body walks forward while
|
||||||
|
|
@ -585,15 +679,14 @@ public sealed class PlayerMovementController
|
||||||
if (withinArrival && aligned)
|
if (withinArrival && aligned)
|
||||||
{
|
{
|
||||||
EndServerAutoWalk("arrived");
|
EndServerAutoWalk("arrived");
|
||||||
return input;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Walk vs run decided ONCE at BeginServerAutoWalk based on
|
// Walk vs run uses the one-shot decision from BeginServerAutoWalk
|
||||||
// initial distance — held for the rest of the auto-walk so the
|
// (initial distance minus use-radius vs walkRunThreshold).
|
||||||
// character keeps running all the way to the target instead of
|
// Held for the rest of the auto-walk so the body runs all
|
||||||
// transitioning to a walk inside the threshold. Matches user-
|
// the way to a far target, or walks all the way to a near
|
||||||
// observed retail behaviour ("if its far away it should run
|
// one — matching user-observed retail behaviour.
|
||||||
// all the way to the object and then stop").
|
|
||||||
bool shouldRun = _autoWalkInitiallyRunning;
|
bool shouldRun = _autoWalkInitiallyRunning;
|
||||||
|
|
||||||
// Turn-first gate: if not yet within the 30° walking band,
|
// Turn-first gate: if not yet within the 30° walking band,
|
||||||
|
|
@ -603,22 +696,56 @@ public sealed class PlayerMovementController
|
||||||
// into it.
|
// into it.
|
||||||
bool moveForward = walkAligned && !withinArrival;
|
bool moveForward = walkAligned && !withinArrival;
|
||||||
|
|
||||||
// Synthesize "moving forward" input. The rest of Update reads
|
if (!moveForward)
|
||||||
// Yaw + input.Forward + input.Run to drive _motion + body
|
|
||||||
// velocity exactly as it does for user-driven W (+ optional Shift).
|
|
||||||
// We zero any mouse delta so a stale frame doesn't fight the
|
|
||||||
// steering.
|
|
||||||
return input with
|
|
||||||
{
|
{
|
||||||
Forward = moveForward,
|
// Turn-in-place phase. The motion state needs to be Ready
|
||||||
Run = moveForward && shouldRun,
|
// (or at least not RunForward) so we're not pretending to
|
||||||
Backward = false,
|
// run while standing. _motion stays at whatever the user-
|
||||||
StrafeLeft = false,
|
// input pipeline last set it to (typically Ready/null since
|
||||||
StrafeRight = false,
|
// the user isn't pressing W). Don't touch body velocity
|
||||||
TurnLeft = false,
|
// either — physics will handle gravity-only behaviour.
|
||||||
TurnRight = false,
|
return true;
|
||||||
MouseDeltaX = 0f,
|
}
|
||||||
};
|
|
||||||
|
// Drive motion state machine + body velocity directly. This
|
||||||
|
// mirrors what the user-input section would have done with
|
||||||
|
// synthesized Forward+Run, but without putting anything into
|
||||||
|
// MovementInput — so the outbound-packet pipeline never builds
|
||||||
|
// a MoveToState packet for auto-walk frames.
|
||||||
|
uint forwardCmd;
|
||||||
|
float forwardCmdSpeed;
|
||||||
|
if (shouldRun && _weenie.InqRunRate(out float runRate))
|
||||||
|
{
|
||||||
|
// Wire-compatible: WalkForward command @ runRate triggers
|
||||||
|
// ACE's auto-upgrade to RunForward for observers. Same
|
||||||
|
// shape as the user-input section's running path.
|
||||||
|
forwardCmd = MotionCommand.WalkForward;
|
||||||
|
forwardCmdSpeed = runRate;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
forwardCmd = MotionCommand.WalkForward;
|
||||||
|
forwardCmdSpeed = 1.0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
_autoWalkMovingForwardThisFrame = true;
|
||||||
|
|
||||||
|
// Update interpreted motion state — drives the animation cycle
|
||||||
|
// via UpdatePlayerAnimation downstream + the MotionInterpreter's
|
||||||
|
// state-velocity getter (used for our velocity assignment below).
|
||||||
|
_motion.DoMotion(forwardCmd, forwardCmdSpeed);
|
||||||
|
|
||||||
|
// Set body velocity directly. Only meaningful when grounded;
|
||||||
|
// mirror the user-input section's `if (_body.OnWalkable)` gate
|
||||||
|
// so we don't override gravity/jump velocity mid-air.
|
||||||
|
if (_body.OnWalkable)
|
||||||
|
{
|
||||||
|
float savedWorldVz = _body.Velocity.Z;
|
||||||
|
var stateVel = _motion.get_state_velocity();
|
||||||
|
_body.set_local_velocity(new Vector3(0f, stateVel.Y, savedWorldVz));
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// L.2a slice 1 (2026-05-12): centralized CellId mutation so the
|
// L.2a slice 1 (2026-05-12): centralized CellId mutation so the
|
||||||
|
|
@ -664,14 +791,22 @@ public sealed class PlayerMovementController
|
||||||
{
|
{
|
||||||
_simTimeSeconds += dt;
|
_simTimeSeconds += dt;
|
||||||
|
|
||||||
// B.6 slice 2 (2026-05-14): server-initiated auto-walk overlay.
|
// 2026-05-16 (issue #75 refactor): server-initiated auto-walk
|
||||||
// When _autoWalkActive, steer Yaw toward _autoWalkDestination and
|
// drives the body's velocity + motion state machine DIRECTLY.
|
||||||
// synthesize Forward+Run input so the rest of Update runs the
|
// When _autoWalkActive, DriveServerAutoWalk steps Yaw, computes
|
||||||
// body forward as if the user were holding W. User movement-key
|
// velocity from wire-supplied runRate, calls _motion.DoMotion,
|
||||||
// input cancels the auto-walk (retail UX). Arrival check fires
|
// and sets _body.set_local_velocity. The user-input motion +
|
||||||
// before synthesizing, so a one-frame arrival doesn't waste a
|
// velocity sections below are SKIPPED so they don't override
|
||||||
// tick of velocity past the target.
|
// the auto-walk's assignments. Critically, no synthesized input
|
||||||
input = ApplyAutoWalkOverlay(dt, input);
|
// gets put back into `input` — the outbound-packet pipeline at
|
||||||
|
// GameWindow.cs:6410 sees user-input null/Ready throughout the
|
||||||
|
// auto-walk and never builds a MoveToState packet, leaving
|
||||||
|
// ACE's server-side MoveToChain to run uninterrupted until its
|
||||||
|
// TryUseItem/TryPickUp callback fires. Retail equivalent:
|
||||||
|
// MovementManager::PerformMovement case 6 (decomp 0x00524440)
|
||||||
|
// calls CPhysicsObj::MoveToObject server-side; the local body
|
||||||
|
// is moved without ever touching CommandInterpreter input.
|
||||||
|
bool autoWalkConsumedMotion = DriveServerAutoWalk(dt, input);
|
||||||
|
|
||||||
// Portal-space guard: while teleporting, no input is processed and
|
// Portal-space guard: while teleporting, no input is processed and
|
||||||
// no physics is resolved. Return a zero-movement result so the caller
|
// no physics is resolved. Return a zero-movement result so the caller
|
||||||
|
|
@ -719,6 +854,14 @@ public sealed class PlayerMovementController
|
||||||
_body.Orientation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, Yaw - MathF.PI / 2f);
|
_body.Orientation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, Yaw - MathF.PI / 2f);
|
||||||
|
|
||||||
// ── 2. Set velocity via MotionInterpreter state machine ───────────────
|
// ── 2. Set velocity via MotionInterpreter state machine ───────────────
|
||||||
|
// 2026-05-16 (issue #75): skip when DriveServerAutoWalk owns
|
||||||
|
// motion control this frame — it has already called
|
||||||
|
// _motion.DoMotion + _body.set_local_velocity from the auto-
|
||||||
|
// walk's path data + runRate. Running this section would
|
||||||
|
// overwrite the auto-walk velocity with the user-input
|
||||||
|
// (Ready/Stand) velocity, freezing the body.
|
||||||
|
if (!autoWalkConsumedMotion)
|
||||||
|
{
|
||||||
// Determine the dominant forward/backward command and speed.
|
// Determine the dominant forward/backward command and speed.
|
||||||
uint forwardCmd;
|
uint forwardCmd;
|
||||||
float forwardCmdSpeed;
|
float forwardCmdSpeed;
|
||||||
|
|
@ -806,6 +949,7 @@ public sealed class PlayerMovementController
|
||||||
|
|
||||||
_body.set_local_velocity(new Vector3(localX, localY, savedWorldVz));
|
_body.set_local_velocity(new Vector3(localX, localY, savedWorldVz));
|
||||||
}
|
}
|
||||||
|
} // end of `if (!autoWalkConsumedMotion)` — section 2
|
||||||
|
|
||||||
// ── 3. Jump (charged) ─────────────────────────────────────────────────
|
// ── 3. Jump (charged) ─────────────────────────────────────────────────
|
||||||
// Hold spacebar to charge (0→1 over JumpChargeRate seconds).
|
// Hold spacebar to charge (0→1 over JumpChargeRate seconds).
|
||||||
|
|
@ -1243,46 +1387,55 @@ public sealed class PlayerMovementController
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 8. Heartbeat timer (always while in-world, not just while moving) ─
|
// ── 8. Heartbeat timer (always while in-world, not just while moving) ─
|
||||||
// 2026-05-16 — retail diff-driven AP cadence (acclient_2013_pseudo_c.txt
|
// 2026-05-16 (closes #74) — retail-faithful AP cadence per
|
||||||
// 0x006b45e0 ShouldSendPositionEvent + 0x006b4770 SendPositionEvent).
|
// CommandInterpreter::ShouldSendPositionEvent at
|
||||||
|
// acclient_2013_pseudo_c.txt:700233-700285. Two-branch:
|
||||||
//
|
//
|
||||||
// Rules:
|
// Branch 1 — interval NOT yet elapsed (< 1 sec since last
|
||||||
// - When interval elapsed (>= 1 sec since last send): send.
|
// send): send only if cell changed OR contact-plane changed
|
||||||
// - When interval NOT elapsed: send only if position or cell
|
// (mid-walk events that matter — stair / hill / cell cross).
|
||||||
// differs from last_sent (Frame::is_equal check at
|
|
||||||
// acclient_2013_pseudo_c.txt:700248-700265).
|
|
||||||
// - SendPositionEvent (acclient_2013_pseudo_c.txt:700327)
|
|
||||||
// gates on `((state & 1) != 0 && (state & 2) != 0)` —
|
|
||||||
// Contact (CONTACT_TS bit 0) AND OnWalkable (ON_WALKABLE_TS
|
|
||||||
// bit 1) BOTH set. Two independent `& != 0` tests joined
|
|
||||||
// by `&&`, NOT a single bitwise-OR mask test. Airborne
|
|
||||||
// (neither bit) and wall-contact-without-walkable (Contact
|
|
||||||
// only) both suppress AP. MoveToState carries jump/fall
|
|
||||||
// snapshots while airborne.
|
|
||||||
//
|
//
|
||||||
// Effective rate: per-frame while moving on the ground, 1 Hz at-rest
|
// Branch 2 — interval HAS elapsed (>= 1 sec): send only if
|
||||||
// heartbeat, 0 Hz airborne. Retires the 1 Hz / 10 Hz flat model.
|
// cell OR position frame changed. Truly idle = no send
|
||||||
|
// (retail's `last_sent.frame == player.frame` check at
|
||||||
|
// acclient_2013_pseudo_c.txt:700248-700265).
|
||||||
//
|
//
|
||||||
// If NotePositionSent has never been called (no network session),
|
// SendPositionEvent (line 700327) gates the actual send on
|
||||||
// _lastSentInitialized stays false and we treat every frame as
|
// (state & 1) != 0 && (state & 2) != 0 — Contact AND
|
||||||
// "first send" — HeartbeatDue fires once per frame, which matches
|
// OnWalkable both set. We mirror that gate so airborne and
|
||||||
// "send if anything to send" semantics.
|
// wall-contact-without-walkable suppress AP entirely;
|
||||||
|
// MoveToState carries jump/fall snapshots while airborne.
|
||||||
|
//
|
||||||
|
// Effective rates:
|
||||||
|
// Truly idle (grounded, no movement) : 0 Hz
|
||||||
|
// Smooth movement (no cell/plane changes) : ~1 Hz (interval)
|
||||||
|
// Cell crossings + stair/hill steps : per-event
|
||||||
|
// Airborne : 0 Hz
|
||||||
|
//
|
||||||
|
// Bootstrap: when NotePositionSent has never been called
|
||||||
|
// (_lastSentInitialized=false), every state-changed branch is
|
||||||
|
// forced true so the first AP gets a chance to fire.
|
||||||
|
|
||||||
bool intervalElapsed = !_lastSentInitialized
|
bool intervalElapsed = !_lastSentInitialized
|
||||||
|| (_simTimeSeconds - _lastSentTime) >= HeartbeatInterval;
|
|| (_simTimeSeconds - _lastSentTime) >= HeartbeatInterval;
|
||||||
|
|
||||||
bool positionChanged =
|
bool cellChanged = !_lastSentInitialized
|
||||||
!_lastSentInitialized
|
|| _lastSentCellId != CellId;
|
||||||
|| _lastSentCellId != CellId
|
bool planeChanged = !_lastSentInitialized
|
||||||
|
|| !ApproxPlaneEqual(_lastSentContactPlane, _body.ContactPlane);
|
||||||
|
bool frameChanged = !_lastSentInitialized
|
||||||
|| !ApproxPositionEqual(_lastSentPos, _body.Position);
|
|| !ApproxPositionEqual(_lastSentPos, _body.Position);
|
||||||
|
|
||||||
// Grounded-on-walkable. Retail's CONTACT_TS + ON_WALKABLE_TS
|
bool sendThisFrame = intervalElapsed
|
||||||
// (acclient.h:3688). Our equivalent: PhysicsBody.InContact &&
|
? (cellChanged || frameChanged)
|
||||||
// PhysicsBody.OnWalkable (both map to TransientStateFlags bits 0+1
|
: (cellChanged || planeChanged);
|
||||||
// which are set together by ResolveWithTransition on walkable ground).
|
|
||||||
|
// Grounded-on-walkable gate per acclient_2013_pseudo_c.txt:700327
|
||||||
|
// (`(state & 1) != 0 && (state & 2) != 0`). Both flags must be
|
||||||
|
// set simultaneously, NOT a bitwise-OR mask test.
|
||||||
bool groundedOnWalkable = _body.InContact && _body.OnWalkable;
|
bool groundedOnWalkable = _body.InContact && _body.OnWalkable;
|
||||||
|
|
||||||
HeartbeatDue = groundedOnWalkable && (positionChanged || intervalElapsed);
|
HeartbeatDue = groundedOnWalkable && sendThisFrame;
|
||||||
|
|
||||||
// K-fix5 (2026-04-26): local-animation-cycle pacing. Visual rate
|
// K-fix5 (2026-04-26): local-animation-cycle pacing. Visual rate
|
||||||
// should match the actual movement speed. For Forward+Run this is
|
// should match the actual movement speed. For Forward+Run this is
|
||||||
|
|
@ -1296,6 +1449,40 @@ public sealed class PlayerMovementController
|
||||||
? (_weenie.InqRunRate(out float vrrAnim) ? vrrAnim : 1f)
|
? (_weenie.InqRunRate(out float vrrAnim) ? vrrAnim : 1f)
|
||||||
: 1f;
|
: 1f;
|
||||||
|
|
||||||
|
// 2026-05-16 (issue #75) — server-initiated auto-walk drives
|
||||||
|
// the local animation cycle directly:
|
||||||
|
// - moving forward → WalkForward / RunForward (legs animate)
|
||||||
|
// - turn-first phase → TurnLeft / TurnRight (issue #69 fix)
|
||||||
|
// - aligned but pre-step / arrival → no override, falls to
|
||||||
|
// the user-input section's default (idle)
|
||||||
|
// UpdatePlayerAnimation reads LocalAnimationCommand +
|
||||||
|
// LocalAnimationSpeed; without these overrides the body
|
||||||
|
// translates/rotates without leg/arm animation. The motion
|
||||||
|
// cycle commands here flow into the animation sequencer
|
||||||
|
// ONLY — the wire-layer guard at GameWindow.cs:6419 prevents
|
||||||
|
// them from leaking to a user-MoveToState packet during
|
||||||
|
// auto-walk.
|
||||||
|
if (_autoWalkMovingForwardThisFrame)
|
||||||
|
{
|
||||||
|
if (_autoWalkInitiallyRunning && _weenie.InqRunRate(out float autoWalkRunRate))
|
||||||
|
{
|
||||||
|
localAnimCmd = MotionCommand.RunForward;
|
||||||
|
localAnimSpeed = autoWalkRunRate;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
localAnimCmd = MotionCommand.WalkForward;
|
||||||
|
localAnimSpeed = 1f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (_autoWalkTurnDirectionThisFrame != 0)
|
||||||
|
{
|
||||||
|
localAnimCmd = _autoWalkTurnDirectionThisFrame > 0
|
||||||
|
? MotionCommand.TurnLeft
|
||||||
|
: MotionCommand.TurnRight;
|
||||||
|
localAnimSpeed = 1f;
|
||||||
|
}
|
||||||
|
|
||||||
return new MovementResult(
|
return new MovementResult(
|
||||||
Position: Position,
|
Position: Position,
|
||||||
RenderPosition: RenderPosition,
|
RenderPosition: RenderPosition,
|
||||||
|
|
@ -1339,4 +1526,24 @@ public sealed class PlayerMovementController
|
||||||
&& MathF.Abs(a.Y - b.Y) < Epsilon
|
&& MathF.Abs(a.Y - b.Y) < Epsilon
|
||||||
&& MathF.Abs(a.Z - b.Z) < Epsilon;
|
&& MathF.Abs(a.Z - b.Z) < Epsilon;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 2026-05-16. Contact-plane-equality test for retail's
|
||||||
|
/// sub-interval AP gate. Retail's SendPositionEvent stores
|
||||||
|
/// last_sent_contact_plane and ShouldSendPositionEvent re-sends
|
||||||
|
/// during the sub-interval window if the plane has changed (e.g.,
|
||||||
|
/// player stepped onto stairs / a hill — same cell but different
|
||||||
|
/// contact normal). Tiny epsilon on normal + distance covers
|
||||||
|
/// floating-point noise from the physics integration.
|
||||||
|
/// </summary>
|
||||||
|
private static bool ApproxPlaneEqual(
|
||||||
|
System.Numerics.Plane a, System.Numerics.Plane b)
|
||||||
|
{
|
||||||
|
const float NormalEpsilon = 1e-4f;
|
||||||
|
const float DistanceEpsilon = 0.001f;
|
||||||
|
return MathF.Abs(a.Normal.X - b.Normal.X) < NormalEpsilon
|
||||||
|
&& MathF.Abs(a.Normal.Y - b.Normal.Y) < NormalEpsilon
|
||||||
|
&& MathF.Abs(a.Normal.Z - b.Normal.Z) < NormalEpsilon
|
||||||
|
&& MathF.Abs(a.D - b.D) < DistanceEpsilon;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6407,7 +6407,23 @@ public sealed class GameWindow : IDisposable
|
||||||
var wireRot = YawToAcQuaternion(_playerController.Yaw);
|
var wireRot = YawToAcQuaternion(_playerController.Yaw);
|
||||||
byte contactByte = result.IsOnGround ? (byte)1 : (byte)0;
|
byte contactByte = result.IsOnGround ? (byte)1 : (byte)0;
|
||||||
|
|
||||||
if (result.MotionStateChanged)
|
// 2026-05-16 (issue #75): wire-layer semantic gate —
|
||||||
|
// user-MoveToState packets are ONLY for user-initiated
|
||||||
|
// motion intent. During server-controlled auto-walk
|
||||||
|
// (inbound MoveToObject), motion-state transitions
|
||||||
|
// come from the auto-walk's animation override, not
|
||||||
|
// from user input. Sending a MoveToState in that case
|
||||||
|
// would tell ACE "user took control" and cancel its
|
||||||
|
// own MoveToChain. This is NOT a band-aid like the
|
||||||
|
// earlier grace-period — it's the wire-layer's
|
||||||
|
// expression of retail's architectural split between
|
||||||
|
// user-input motion and server-driven motion: they
|
||||||
|
// share the local motion-state machine but only
|
||||||
|
// user-input flows back to the wire. Without the
|
||||||
|
// refactor (issue #75) this guard masked a synthesis
|
||||||
|
// leak; with the refactor it expresses the proper
|
||||||
|
// semantic.
|
||||||
|
if (result.MotionStateChanged && !_playerController.IsServerAutoWalking)
|
||||||
{
|
{
|
||||||
// HoldKey axis values — retail enum (holtburger types.rs HoldKey):
|
// HoldKey axis values — retail enum (holtburger types.rs HoldKey):
|
||||||
// Invalid = 0, None = 1, Run = 2
|
// Invalid = 0, None = 1, Run = 2
|
||||||
|
|
@ -6446,11 +6462,13 @@ public sealed class GameWindow : IDisposable
|
||||||
// B.6/B.7 (2026-05-16): stamp the diff-driven heartbeat clock so
|
// B.6/B.7 (2026-05-16): stamp the diff-driven heartbeat clock so
|
||||||
// HeartbeatDue resets its interval from THIS send — mirrors retail's
|
// HeartbeatDue resets its interval from THIS send — mirrors retail's
|
||||||
// SendMovementEvent (acclient_2013_pseudo_c.txt:0x006b4680) writing
|
// SendMovementEvent (acclient_2013_pseudo_c.txt:0x006b4680) writing
|
||||||
// last_sent_position_time + last_sent_position after each MTS send.
|
// last_sent_position_time + last_sent_position + contact_plane
|
||||||
|
// after each MTS send.
|
||||||
_playerController.NotePositionSent(
|
_playerController.NotePositionSent(
|
||||||
worldPos: _playerController.Position,
|
worldPos: _playerController.Position,
|
||||||
cellId: _playerController.CellId,
|
cellId: _playerController.CellId,
|
||||||
nowSeconds: _playerController.SimTimeSeconds);
|
contactPlane: _playerController.ContactPlane,
|
||||||
|
nowSeconds: _playerController.SimTimeSeconds);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_playerController.HeartbeatDue)
|
if (_playerController.HeartbeatDue)
|
||||||
|
|
@ -6472,11 +6490,13 @@ public sealed class GameWindow : IDisposable
|
||||||
// B.6/B.7 (2026-05-16): stamp the diff-driven heartbeat clock so
|
// B.6/B.7 (2026-05-16): stamp the diff-driven heartbeat clock so
|
||||||
// HeartbeatDue resets its interval from THIS send — mirrors retail's
|
// HeartbeatDue resets its interval from THIS send — mirrors retail's
|
||||||
// SendPositionEvent (acclient_2013_pseudo_c.txt:700345-700348)
|
// SendPositionEvent (acclient_2013_pseudo_c.txt:700345-700348)
|
||||||
// writing last_sent_position_time + last_sent_position after each AP.
|
// writing last_sent_position_time + last_sent_position +
|
||||||
|
// last_sent_contact_plane after each AP.
|
||||||
_playerController.NotePositionSent(
|
_playerController.NotePositionSent(
|
||||||
worldPos: _playerController.Position,
|
worldPos: _playerController.Position,
|
||||||
cellId: _playerController.CellId,
|
cellId: _playerController.CellId,
|
||||||
nowSeconds: _playerController.SimTimeSeconds);
|
contactPlane: _playerController.ContactPlane,
|
||||||
|
nowSeconds: _playerController.SimTimeSeconds);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.JumpExtent.HasValue && result.JumpVelocity.HasValue)
|
if (result.JumpExtent.HasValue && result.JumpVelocity.HasValue)
|
||||||
|
|
@ -9106,45 +9126,51 @@ public sealed class GameWindow : IDisposable
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2026-05-16 — R is conceptually "use." It smart-routes to
|
// 2026-05-16 (Phase B.6 follow-up) — R is the universal "interact"
|
||||||
// pickup as a downstream optimization (see the isPickupableItem
|
// key. Retail dispatches by TARGET TYPE first; the useability gate
|
||||||
// dispatch below), but the GATE is always IsUseableTarget —
|
// is enforced by each individual action handler (SendUse checks
|
||||||
// what retail's UseObject would do.
|
// IsUseableTarget; SendPickUp checks IsPickupableTarget), not as
|
||||||
|
// a top-level block. Previously the IsUseableTarget gate at the
|
||||||
|
// entry point rejected USEABLE_NO=1 items (spell components,
|
||||||
|
// gems) which retail accepts as pickupable — they just aren't
|
||||||
|
// "useable" in the activate-from-world sense.
|
||||||
|
//
|
||||||
|
// Dispatch order:
|
||||||
|
// 1. Creature → SendUse (talk to NPC, attack monster)
|
||||||
|
// 2. Pickupable → SendPickUp (small items, corpses)
|
||||||
|
// 3. Useable → SendUse (doors, portals, lifestones,
|
||||||
|
// potions / scrolls activated from world)
|
||||||
|
// 4. Else → toast "X cannot be used" (signs, banners,
|
||||||
|
// decorative scenery)
|
||||||
|
//
|
||||||
// Retail string at acclient_2013_pseudo_c.txt:1033115
|
// Retail string at acclient_2013_pseudo_c.txt:1033115
|
||||||
// (data_7e2a70): "The %s cannot be used".
|
// (data_7e2a70): "The %s cannot be used".
|
||||||
if (!IsUseableTarget(sel))
|
|
||||||
|
bool isCreature = _liveEntityInfoByGuid.TryGetValue(sel, out var info)
|
||||||
|
&& (info.ItemType & AcDream.Core.Items.ItemType.Creature) != 0;
|
||||||
|
|
||||||
|
if (isCreature)
|
||||||
{
|
{
|
||||||
string label = DescribeLiveEntity(sel);
|
SendUse(sel);
|
||||||
_debugVm?.AddToast(AcDream.Core.Ui.RetailMessages.CannotBeUsed(label));
|
|
||||||
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
|
|
||||||
Console.WriteLine($"[B.4b] R-key ignored — not useable guid=0x{sel:X8}");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// B.7 (2026-05-15): the user requested R behave as a universal
|
if (IsPickupableTarget(sel))
|
||||||
// interact key — pickup for items, use for NPCs / doors /
|
|
||||||
// lifestones / portals / corpses. Matches retail's "use"
|
|
||||||
// behaviour where the action picked depends on the target's
|
|
||||||
// type rather than forcing the player to remember a different
|
|
||||||
// hotkey per target type.
|
|
||||||
bool isPickupableItem = true;
|
|
||||||
if (_liveEntityInfoByGuid.TryGetValue(sel, out var info)
|
|
||||||
&& (info.ItemType & AcDream.Core.Items.ItemType.Creature) != 0)
|
|
||||||
{
|
{
|
||||||
// NPCs / monsters / players are Use targets, never PickUp.
|
SendPickUp(sel);
|
||||||
isPickupableItem = false;
|
return;
|
||||||
}
|
|
||||||
if (_lastSpawnByGuid.TryGetValue(sel, out var spawn)
|
|
||||||
&& spawn.ObjectDescriptionFlags is { } odf)
|
|
||||||
{
|
|
||||||
// BF_DOOR | BF_LIFESTONE | BF_PORTAL | BF_CORPSE → Use, not PickUp.
|
|
||||||
// (acclient.h:6431-6463)
|
|
||||||
const uint NonPickupMask = 0x1000u | 0x4000u | 0x40000u | 0x2000u;
|
|
||||||
if ((odf & NonPickupMask) != 0) isPickupableItem = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isPickupableItem) SendPickUp(sel);
|
if (IsUseableTarget(sel))
|
||||||
else SendUse(sel);
|
{
|
||||||
|
SendUse(sel);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
string label = DescribeLiveEntity(sel);
|
||||||
|
_debugVm?.AddToast(AcDream.Core.Ui.RetailMessages.CannotBeUsed(label));
|
||||||
|
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
|
||||||
|
Console.WriteLine($"[B.4b] R-key ignored — neither pickupable nor useable guid=0x{sel:X8}");
|
||||||
}
|
}
|
||||||
|
|
||||||
private void SendUse(uint guid)
|
private void SendUse(uint guid)
|
||||||
|
|
@ -9188,22 +9214,17 @@ public sealed class GameWindow : IDisposable
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Far range: send Use immediately so ACE has the request,
|
// Far range: send Use ONCE. ACE's CreateMoveToChain
|
||||||
// AND queue an arrival re-send. Issue #63 (server-initiated
|
// (Player_Use.cs:205) holds a callback (TryUseItem) and fires
|
||||||
// MoveToObject not honored) means ACE's first-Use response
|
// it server-side when WithinUseRadius passes during the
|
||||||
// is dropped as too-far and ACE doesn't re-poll
|
// MoveToChain poll (Player_Move.cs:150). No client-side retry
|
||||||
// WithinUseRadius when the speculative local walk gets us in
|
// needed — the Phase B.6 MoveToState-suppression fix
|
||||||
// range. The arrival re-send fires a second Use packet once
|
// (GameWindow.cs:6410) keeps ACE's chain alive during the
|
||||||
// the body reaches the target — at which point ACE accepts
|
// walk.
|
||||||
// and executes the action. The retail-faithful path is to
|
|
||||||
// honor MoveToObject and let ACE complete the Use server-
|
|
||||||
// side; until #63 lands, this client-side retry is the
|
|
||||||
// workaround that keeps far-range Use working.
|
|
||||||
var seq = _liveSession.NextGameActionSequence();
|
var seq = _liveSession.NextGameActionSequence();
|
||||||
var body = AcDream.Core.Net.Messages.InteractRequests.BuildUse(seq, guid);
|
var body = AcDream.Core.Net.Messages.InteractRequests.BuildUse(seq, guid);
|
||||||
_liveSession.SendGameAction(body);
|
_liveSession.SendGameAction(body);
|
||||||
_pendingPostArrivalAction = (guid, false);
|
Console.WriteLine($"[B.4b] use guid=0x{guid:X8} seq={seq}");
|
||||||
Console.WriteLine($"[B.4b] use guid=0x{guid:X8} seq={seq} (queued for arrival re-send pending #63)");
|
|
||||||
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
|
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
|
||||||
{
|
{
|
||||||
string label = DescribeLiveEntity(guid);
|
string label = DescribeLiveEntity(guid);
|
||||||
|
|
@ -9263,16 +9284,16 @@ public sealed class GameWindow : IDisposable
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Far range: same arrival-retry pattern as SendUse — fire
|
// Far range: send PickUp ONCE. Same auto-fire-via-MoveToChain
|
||||||
// PickUp immediately AND queue for arrival re-send. ACE's
|
// callback pattern as SendUse — ACE's chain fires
|
||||||
// first PickUp is dropped if we're outside the use-radius
|
// PutItemInContainer/Move server-side when in range. No
|
||||||
// and isn't re-polled (issue #63 workaround).
|
// client-side retry; Phase B.6 MoveToState suppression keeps
|
||||||
|
// ACE's chain alive.
|
||||||
var seq = _liveSession.NextGameActionSequence();
|
var seq = _liveSession.NextGameActionSequence();
|
||||||
var body = AcDream.Core.Net.Messages.InteractRequests.BuildPickUp(
|
var body = AcDream.Core.Net.Messages.InteractRequests.BuildPickUp(
|
||||||
seq, itemGuid, _playerServerGuid, placement: 0);
|
seq, itemGuid, _playerServerGuid, placement: 0);
|
||||||
_liveSession.SendGameAction(body);
|
_liveSession.SendGameAction(body);
|
||||||
_pendingPostArrivalAction = (itemGuid, true);
|
Console.WriteLine($"[B.5] pickup item=0x{itemGuid:X8} container=0x{_playerServerGuid:X8} seq={seq}");
|
||||||
Console.WriteLine($"[B.5] pickup item=0x{itemGuid:X8} container=0x{_playerServerGuid:X8} seq={seq} (queued for arrival re-send pending #63)");
|
|
||||||
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
|
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
|
||||||
{
|
{
|
||||||
string label = DescribeLiveEntity(itemGuid);
|
string label = DescribeLiveEntity(itemGuid);
|
||||||
|
|
@ -9665,56 +9686,75 @@ public sealed class GameWindow : IDisposable
|
||||||
/// pickup flow for entities where ACE didn't publish useability.
|
/// pickup flow for entities where ACE didn't publish useability.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <summary>
|
||||||
|
/// 2026-05-16 — pickup gate is ItemType-based, NOT useability-based.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// The earlier <c>(useability & USEABLE_REMOTE) != 0u</c> check
|
||||||
|
/// was a misread of the audit. USEABLE_REMOTE (0x20) gates the USE
|
||||||
|
/// action ("can the player activate this item from the world");
|
||||||
|
/// PICKUP is a separate action governed by retail's
|
||||||
|
/// PutItemInContainer handler, which accepts any small-item-class
|
||||||
|
/// entity from the world regardless of useability bits. A spell
|
||||||
|
/// component with useability=USEABLE_NO=1 is still pickupable in
|
||||||
|
/// retail — USEABLE_NO blocks using the component (you can't
|
||||||
|
/// "activate" it standalone), not picking it up.
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Now matches retail: small-item ItemType class OR BF_CORPSE bit
|
||||||
|
/// → pickupable. Server validates the request server-side
|
||||||
|
/// (in-range, target-still-exists, container-has-room).
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
private bool IsPickupableTarget(uint guid)
|
private bool IsPickupableTarget(uint guid)
|
||||||
{
|
{
|
||||||
if (_lastSpawnByGuid.TryGetValue(guid, out var spawn))
|
if (!_lastSpawnByGuid.TryGetValue(guid, out var spawn))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
// 2026-05-16 — primary discriminator is the BF_STUCK
|
||||||
|
// ObjectDescriptionFlag (acclient.h:6435, bit 0x4). Retail and
|
||||||
|
// ACE mark immovable world objects (signs, banners, doors,
|
||||||
|
// benches) as Stuck server-side. ACE's PutItemInContainer
|
||||||
|
// handler (Player_Inventory.cs:831-836) responds with
|
||||||
|
// WeenieError.Stuck (0x29) when the client attempts a pickup
|
||||||
|
// on an item with the Stuck flag — so the client should gate
|
||||||
|
// out signs etc. before sending the wire packet.
|
||||||
|
//
|
||||||
|
// Discriminates same-ItemType ambiguity that useability can't:
|
||||||
|
// Holtburg sign (Misc + USEABLE_NO + BF_STUCK) → block
|
||||||
|
// Spell component (Misc + USEABLE_NO + ~BF_STUCK) → allow
|
||||||
|
// Door (no SmallItemMask + BF_DOOR + BF_STUCK) → never matches SmallItemMask, separately
|
||||||
|
if (spawn.ObjectDescriptionFlags is { } odf)
|
||||||
{
|
{
|
||||||
if (spawn.Useability is uint useability)
|
const uint BF_STUCK = 0x0004u;
|
||||||
{
|
const uint BF_CORPSE = 0x2000u;
|
||||||
const uint USEABLE_REMOTE = 0x20u;
|
// Corpses are pickupable (loot) — BF_CORPSE wins over
|
||||||
return (useability & USEABLE_REMOTE) != 0u;
|
// any BF_STUCK that might be coincidentally set.
|
||||||
}
|
if ((odf & BF_CORPSE) != 0u) return true;
|
||||||
|
// Anything else with BF_STUCK is immovable scenery.
|
||||||
// Useability null: corpses are pickupable; signs aren't.
|
if ((odf & BF_STUCK) != 0u) return false;
|
||||||
if (spawn.ObjectDescriptionFlags is { } odf)
|
|
||||||
{
|
|
||||||
const uint BF_CORPSE = 0x2000u;
|
|
||||||
if ((odf & BF_CORPSE) != 0u)
|
|
||||||
{
|
|
||||||
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeUseabilityFallbackEnabled)
|
|
||||||
Console.WriteLine(System.FormattableString.Invariant(
|
|
||||||
$"[useability-fallback] pickup-corpse guid=0x{guid:X8} (ACE sent no useability bit)"));
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Small-item ItemType fallback (covers F on dropped items
|
|
||||||
// when ACE doesn't publish useability for the weenie).
|
|
||||||
uint it = spawn.ItemType ?? 0u;
|
|
||||||
const uint SmallItemMask =
|
|
||||||
(uint)(AcDream.Core.Items.ItemType.MeleeWeapon
|
|
||||||
| AcDream.Core.Items.ItemType.Armor
|
|
||||||
| AcDream.Core.Items.ItemType.Clothing
|
|
||||||
| AcDream.Core.Items.ItemType.Jewelry
|
|
||||||
| AcDream.Core.Items.ItemType.Food
|
|
||||||
| AcDream.Core.Items.ItemType.Money
|
|
||||||
| AcDream.Core.Items.ItemType.Misc
|
|
||||||
| AcDream.Core.Items.ItemType.MissileWeapon
|
|
||||||
| AcDream.Core.Items.ItemType.Container
|
|
||||||
| AcDream.Core.Items.ItemType.Gem
|
|
||||||
| AcDream.Core.Items.ItemType.SpellComponents
|
|
||||||
| AcDream.Core.Items.ItemType.Writable
|
|
||||||
| AcDream.Core.Items.ItemType.Key
|
|
||||||
| AcDream.Core.Items.ItemType.Caster);
|
|
||||||
if ((it & SmallItemMask) != 0u)
|
|
||||||
{
|
|
||||||
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeUseabilityFallbackEnabled)
|
|
||||||
Console.WriteLine(System.FormattableString.Invariant(
|
|
||||||
$"[useability-fallback] pickup-smallitem guid=0x{guid:X8} itemType=0x{it:X8} (ACE sent no useability bit)"));
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return false;
|
|
||||||
|
// Small-item ItemType class: dropped weapons, armor, food,
|
||||||
|
// jewelry, money, misc, gems, spell components, etc.
|
||||||
|
uint it = spawn.ItemType ?? 0u;
|
||||||
|
const uint SmallItemMask =
|
||||||
|
(uint)(AcDream.Core.Items.ItemType.MeleeWeapon
|
||||||
|
| AcDream.Core.Items.ItemType.Armor
|
||||||
|
| AcDream.Core.Items.ItemType.Clothing
|
||||||
|
| AcDream.Core.Items.ItemType.Jewelry
|
||||||
|
| AcDream.Core.Items.ItemType.Food
|
||||||
|
| AcDream.Core.Items.ItemType.Money
|
||||||
|
| AcDream.Core.Items.ItemType.Misc
|
||||||
|
| AcDream.Core.Items.ItemType.MissileWeapon
|
||||||
|
| AcDream.Core.Items.ItemType.Container
|
||||||
|
| AcDream.Core.Items.ItemType.Gem
|
||||||
|
| AcDream.Core.Items.ItemType.SpellComponents
|
||||||
|
| AcDream.Core.Items.ItemType.Writable
|
||||||
|
| AcDream.Core.Items.ItemType.Key
|
||||||
|
| AcDream.Core.Items.ItemType.Caster);
|
||||||
|
return (it & SmallItemMask) != 0u;
|
||||||
}
|
}
|
||||||
|
|
||||||
private string DescribeLiveEntity(uint guid)
|
private string DescribeLiveEntity(uint guid)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue