acdream/docs/superpowers/plans/2026-05-16-phase-b6-suppress-movetostate-during-inbound-autowalk.md
Erik d640ed74e1 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>
2026-05-16 16:14:44 +02:00

29 KiB
Raw Blame History

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.

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:

                if (result.MotionStateChanged)
                {
                    // HoldKey axis values — retail enum (holtburger types.rs HoldKey):

Change the condition to:

                // 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):

        // 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:

        // 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:

        // 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:

        // 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:

$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 15 pass.

  • Step 7: Commit
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):

    private System.Numerics.Vector3 _lastSentPos;
    private uint _lastSentCellId;
    private float _lastSentTime;
    private bool _lastSentInitialized;

Add a new field immediately after _lastSentCellId:

    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:

    public void NotePositionSent(System.Numerics.Vector3 worldPos,
                                  uint cellId,
                                  float nowSeconds)
    {
        _lastSentPos          = worldPos;
        _lastSentCellId       = cellId;
        _lastSentTime         = nowSeconds;
        _lastSentInitialized  = true;
    }

With:

    /// <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:

        // 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:

                    _playerController.NotePositionSent(
                        worldPos:   _playerController.Position,
                        cellId:     _playerController.CellId,
                        nowSeconds: _playerController.SimTimeSeconds);

With:

                    _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):

    /// <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:

                    _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 15 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
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:

    ## #63 — Server-initiated auto-walk (MoveToObject) not honored
    

    to:

    ## #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":

    **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:

## #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:

**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
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.